diff --git a/README.md b/README.md index 09b21b6..3bce923 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ COMMANDS: flags Retrieve flags for osquery from osctrl and write them locally cert Retrieve server certificate for osquery from osctrl and write it locally service Run as a daemon, periodically syncing flags and certificate + install Enroll this node natively: install osquery if needed, write secret, flags and certificate, and start the service + uninstall Remove this node from osctrl natively: stop the service and delete secret, flags and certificate. osquery itself is left installed check-config Validate configuration and exit default-config Print a default YAML configuration ``` @@ -211,6 +213,32 @@ In daemon mode, osctrld will: 3. Restart osquery through the OS service manager when changes are detected. 4. Shut down gracefully on `SIGINT` or `SIGTERM`. +## Native enrollment + +`osctrld install` does everything the osctrl quick-add script does, without a shell: +it installs osquery when it is missing or out of date, writes the secret, flags and +certificate, and starts and enables the service. `osctrld uninstall` reverses the +configuration — it stops the service and deletes those three files, and deliberately +leaves osquery itself installed. + +Both commands require root on Linux and macOS, and Administrator on Windows. osctrld +never calls `sudo` itself; run it under sudo. + +The osquery package is verified before installation. The SHA-256 comes from the osctrl +server when it provides one, otherwise from `--osquery-sha256`. Without either, the +install is refused unless you pass `--allow-unverified`. + +| Flag | Environment variable | Purpose | +| --- | --- | --- | +| `--osquery-sha256` | `OSQUERY_SHA256` | Expected SHA-256 of the osquery package | +| `--osquery-package` | `OSQUERY_PACKAGE` | Override the package URL, for mirrors and air-gapped installs | +| `--allow-unverified` | `OSCTRL_ALLOW_UNVERIFIED` | Install without verifying the package | + +```bash +sudo osctrld --secret --environment dev --osctrl-url https://osctrl.example.com \ + --osquery-sha256 install +``` + ## 🚢 Deployment ### 🐧 Linux systemd diff --git a/cmd/osctrld/actions.go b/cmd/osctrld/actions.go index 83ed3a5..1607130 100644 --- a/cmd/osctrld/actions.go +++ b/cmd/osctrld/actions.go @@ -56,6 +56,7 @@ type VerifyResponse struct { Flags string `json:"flags"` Certificate string `json:"certificate"` OsqueryVersion string `json:"osquery_version"` + OsquerySHA256 string `json:"osquery_sha256"` } // ExtensionEntry represents a single extension from the manifest @@ -92,7 +93,7 @@ func getFlags(ctx context.Context, cmd *cli.Command) (bool, error) { return false, fmt.Errorf("error retrieving flags - %v", err) } log.Debug().Str("flags", flags).Msg("flags content") - changed, err := writeContentExists(appConfig.OsqueryFlagFile, flags, "flags", appConfig.Force) + changed, err := writeContentExists(appConfig.OsqueryFlagFile, flags, "flags", appConfig.Force, 0700) if err != nil { return false, err } @@ -110,7 +111,7 @@ func getCert(ctx context.Context, cmd *cli.Command) (bool, error) { return false, fmt.Errorf("error retrieving cert - %v", err) } log.Debug().Str("cert", cert).Msg("cert content") - changed, err := writeContentExists(appConfig.OsqueryCertFile, cert, "cert", appConfig.Force) + changed, err := writeContentExists(appConfig.OsqueryCertFile, cert, "cert", appConfig.Force, 0700) if err != nil { return false, err } diff --git a/cmd/osctrld/actions_helpers.go b/cmd/osctrld/actions_helpers.go index 7b5d8f7..26cd863 100644 --- a/cmd/osctrld/actions_helpers.go +++ b/cmd/osctrld/actions_helpers.go @@ -106,26 +106,43 @@ func checkFileContent(path, content string) bool { return (strings.TrimSpace(string(fContent)) == content) } -// Helper function to write content to a file if not different from existing -func writeContentExists(path, content, name string, force bool) (bool, error) { +// Helper function to write content to a file if not different from existing. +// os.WriteFile's mode argument only takes effect when it creates the file: an +// existing file keeps whatever mode it already had, even when we overwrite its +// content. So every successful return path chmods explicitly, to guarantee the +// file is at mode regardless of whether it was created, overwritten, or left +// alone because the content already matched. +func writeContentExists(path, content, name string, force bool, mode os.FileMode) (bool, error) { if checkFileExist(path) { if !checkFileContent(path, content) { if force { - if err := os.WriteFile(path, []byte(content), 0700); err != nil { + if err := os.WriteFile(path, []byte(content), mode); err != nil { return false, fmt.Errorf("error overwriting %s to %s - %v", name, path, err) } + if err := os.Chmod(path, mode); err != nil { + return false, fmt.Errorf("error setting mode on %s - %v", path, err) + } return true, nil } return false, fmt.Errorf("%s exists, please use --force to overwrite", path) } + if err := os.Chmod(path, mode); err != nil { + return false, fmt.Errorf("error setting mode on %s - %v", path, err) + } return false, nil } - if err := os.WriteFile(path, []byte(content), 0700); err != nil { + if err := os.WriteFile(path, []byte(content), mode); err != nil { return false, fmt.Errorf("error writing %s to %s - %v", name, path, err) } + if err := os.Chmod(path, mode); err != nil { + return false, fmt.Errorf("error setting mode on %s - %v", path, err) + } return true, nil } +// osqueryVersionReader is a seam so the install decision can be tested without osqueryd present +var osqueryVersionReader = getOsqueryVersion + // Helper function to execute the "osqueryd -version" command and return output func getOsqueryVersion() string { var osquerydBin string diff --git a/cmd/osctrld/actions_helpers_test.go b/cmd/osctrld/actions_helpers_test.go index 5f29199..59389fa 100644 --- a/cmd/osctrld/actions_helpers_test.go +++ b/cmd/osctrld/actions_helpers_test.go @@ -16,7 +16,7 @@ func TestWriteContentExists_NewFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "newfile.txt") - changed, err := writeContentExists(path, "hello", "test", false) + changed, err := writeContentExists(path, "hello", "test", false, 0700) assert.NoError(t, err) assert.True(t, changed, "new file should report changed") @@ -30,7 +30,7 @@ func TestWriteContentExists_SameContent(t *testing.T) { path := filepath.Join(dir, "existing.txt") require.NoError(t, os.WriteFile(path, []byte("hello"), 0700)) - changed, err := writeContentExists(path, "hello", "test", false) + changed, err := writeContentExists(path, "hello", "test", false, 0700) assert.NoError(t, err) assert.False(t, changed, "same content should not report changed") } @@ -40,7 +40,7 @@ func TestWriteContentExists_DifferentContentNoForce(t *testing.T) { path := filepath.Join(dir, "existing.txt") require.NoError(t, os.WriteFile(path, []byte("old"), 0700)) - changed, err := writeContentExists(path, "new", "test", false) + changed, err := writeContentExists(path, "new", "test", false, 0700) assert.Error(t, err) assert.Contains(t, err.Error(), "please use --force") assert.False(t, changed, "should not report changed on error") @@ -54,7 +54,7 @@ func TestWriteContentExists_DifferentContentWithForce(t *testing.T) { path := filepath.Join(dir, "existing.txt") require.NoError(t, os.WriteFile(path, []byte("old"), 0700)) - changed, err := writeContentExists(path, "new", "test", true) + changed, err := writeContentExists(path, "new", "test", true, 0700) assert.NoError(t, err) assert.True(t, changed, "forced overwrite should report changed") @@ -62,6 +62,50 @@ func TestWriteContentExists_DifferentContentWithForce(t *testing.T) { assert.Equal(t, "new", string(content)) } +func TestWriteContentExistsHonorsMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "secret") + + changed, err := writeContentExists(path, "s3cr3t", "secret", false, 0600) + require.NoError(t, err) + assert.True(t, changed) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +// os.WriteFile applies perm only at creation, so an existing file keeps its old +// mode unless we chmod explicitly. The realistic case is a secret written by the +// osctrl quick-add script at 0644, then migrated to osctrld install. +func TestWriteContentExistsTightensExistingFileMode(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "osquery.secret") + require.NoError(t, os.WriteFile(path, []byte("old-secret"), 0644)) + + _, err := writeContentExists(path, "new-secret", "secret", true, 0600) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), + "an existing secret must be tightened, not left world-readable") +} + +func TestWriteContentExistsTightensModeWhenContentUnchanged(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "osquery.secret") + require.NoError(t, os.WriteFile(path, []byte("same-secret"), 0644)) + + _, err := writeContentExists(path, "same-secret", "secret", false, 0600) + require.NoError(t, err) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), + "matching content must still leave the file at the requested mode") +} + func mockOsctrlServer() *httptest.Server { mux := http.NewServeMux() mux.HandleFunc("/env/osctrld-flags", func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/osctrld/config.go b/cmd/osctrld/config.go index d358f93..07c93eb 100644 --- a/cmd/osctrld/config.go +++ b/cmd/osctrld/config.go @@ -72,6 +72,9 @@ type Configuration struct { LogFormat string `json:"logFormat" yaml:"logFormat" mapstructure:"logFormat"` Interval int `json:"interval" yaml:"interval" mapstructure:"interval"` ExtensionsDir string `json:"extensionsDir" yaml:"extensionsDir" mapstructure:"extensionsDir"` + OsquerySHA256 string `json:"osquerySHA256" yaml:"osquerySHA256" mapstructure:"osquerySHA256"` + OsqueryPackage string `json:"osqueryPackage" yaml:"osqueryPackage" mapstructure:"osqueryPackage"` + AllowUnverified bool `json:"allowUnverified" yaml:"allowUnverified" mapstructure:"allowUnverified"` } type ConfigurationFile struct { @@ -95,6 +98,9 @@ func defaultConfiguration() Configuration { LogFormat: defLogFormat, Interval: defInterval, ExtensionsDir: "/path/to/extensions/", + OsquerySHA256: "sha256-of-the-osquery-package", + OsqueryPackage: "", + AllowUnverified: false, } } @@ -204,6 +210,29 @@ func buildConfigFlags() []cli.Flag { Sources: cli.EnvVars("OSCTRL_INTERVAL"), Destination: &appConfig.Interval, }, + &cli.StringFlag{ + Name: "osquery-sha256", + Aliases: []string{"H"}, + Value: defEmptyValue, + Usage: "Expected SHA-256 of the osquery package, used by the install command", + Sources: cli.EnvVars("OSQUERY_SHA256"), + Destination: &appConfig.OsquerySHA256, + }, + &cli.StringFlag{ + Name: "osquery-package", + Aliases: []string{"P"}, + Value: defEmptyValue, + Usage: "Override the osquery package URL, for mirrors and air-gapped installs", + Sources: cli.EnvVars("OSQUERY_PACKAGE"), + Destination: &appConfig.OsqueryPackage, + }, + &cli.BoolFlag{ + Name: "allow-unverified", + Value: false, + Usage: "Install the osquery package even when no SHA-256 is available to verify it", + Sources: cli.EnvVars("OSCTRL_ALLOW_UNVERIFIED"), + Destination: &appConfig.AllowUnverified, + }, } } diff --git a/cmd/osctrld/config_test.go b/cmd/osctrld/config_test.go index b3d76f9..909f958 100644 --- a/cmd/osctrld/config_test.go +++ b/cmd/osctrld/config_test.go @@ -110,7 +110,7 @@ func TestBuildConfigFlagsIncludesConfigurationDefaults(t *testing.T) { configFlags := buildConfigFlags() - assert.Len(t, configFlags, 13) + assert.Len(t, configFlags, 16) assert.Equal(t, "configuration", configFlags[0].Names()[0]) assert.Equal(t, "secret", configFlags[1].Names()[0]) logFormatFlag, ok := configFlags[11].(*cli.StringFlag) @@ -168,6 +168,18 @@ func TestValidateConfigurationRejectsInvalidValues(t *testing.T) { assert.Contains(t, err.Error(), "interval must be greater than 0") } +func TestBuildConfigFlagsIncludesInstallFlags(t *testing.T) { + configFlags := buildConfigFlags() + + names := map[string]bool{} + for _, f := range configFlags { + names[f.Names()[0]] = true + } + assert.True(t, names["osquery-sha256"], "osquery-sha256 flag missing") + assert.True(t, names["allow-unverified"], "allow-unverified flag missing") + assert.True(t, names["osquery-package"], "osquery-package flag missing") +} + func TestCheckConfigCommandValidatesConfigurationFile(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "osctrld.yaml") diff --git a/cmd/osctrld/extensions.go b/cmd/osctrld/extensions.go index 3a03777..806caf1 100644 --- a/cmd/osctrld/extensions.go +++ b/cmd/osctrld/extensions.go @@ -31,15 +31,10 @@ func downloadExtension(url, destPath string, insecure bool) (bool, error) { if code != http.StatusOK { return false, fmt.Errorf("HTTP %d downloading extension", code) } - changed, err := writeContentExists(destPath, string(body), filepath.Base(destPath), true) + changed, err := writeContentExists(destPath, string(body), filepath.Base(destPath), true, 0755) if err != nil { return false, err } - if changed { - if err := os.Chmod(destPath, 0755); err != nil { - return false, fmt.Errorf("error setting extension permissions - %v", err) - } - } return changed, nil } diff --git a/cmd/osctrld/extensions_test.go b/cmd/osctrld/extensions_test.go index 824259d..58d8198 100644 --- a/cmd/osctrld/extensions_test.go +++ b/cmd/osctrld/extensions_test.go @@ -86,6 +86,11 @@ func TestDownloadExtension_NoChange(t *testing.T) { changed, err := downloadExtension(server.URL, path, false) assert.NoError(t, err) assert.False(t, changed, "same content should not report changed") + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0755), info.Mode().Perm(), + "a resync of unchanged content must not strip the extension's execute bits") } func TestSyncExtensions_Success(t *testing.T) { diff --git a/cmd/osctrld/install.go b/cmd/osctrld/install.go new file mode 100644 index 0000000..d8daaae --- /dev/null +++ b/cmd/osctrld/install.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v3" +) + +// osqueryInstallDecision reports whether the osquery package must be installed. +// A node running NEWER than required is deliberately left alone: silently +// downgrading a fleet is worse than running ahead of the required version, which +// is the same call the osctrl quick-add script makes. An unparseable installed +// version is treated like a missing one. +func osqueryInstallDecision(installed, required string) bool { + if installed == "" { + return true + } + switch osqueryVersionCompare(installed, required) { + case 2: // required is higher than installed + return true + case -1: // one side could not be parsed, treat the node as unknown + return true + default: // equal, or installed is higher + return false + } +} + +// ensureOsquery installs the osquery package when the node is missing it or +// running an older version. The package URL can be overridden for mirrors. +func ensureOsquery(v VerifyResponse) error { + installed := osqueryVersionReader() + log.Debug().Str("installed", installed).Str("required", v.OsqueryVersion).Msg("comparing osquery version") + if !osqueryInstallDecision(installed, v.OsqueryVersion) { + log.Info().Str("version", installed).Msg("osquery is already at a suitable version") + return nil + } + format, err := packageFormatFor(runtime.GOOS, "") + if err != nil { + return err + } + url := appConfig.OsqueryPackage + if url == defEmptyValue { + if url, err = osqueryPackageURL(format, v.OsqueryVersion, runtime.GOARCH); err != nil { + return err + } + } + digest := resolveDigest(v.OsquerySHA256, appConfig.OsquerySHA256) + path, err := spin("downloading osquery "+v.OsqueryVersion, func() (string, error) { + return downloadPackage(url, digest, appConfig.AllowUnverified, appConfig.Insecure) + }) + if err != nil { + return err + } + defer func() { + if err := os.Remove(path); err != nil { + log.Debug().Err(err).Str("path", path).Msg("could not remove the downloaded package") + } + }() + log.Info().Str("format", string(format)).Str("version", v.OsqueryVersion).Msg("installing osquery") + return installPackage(format, path) +} + +// installNode performs natively what the osctrl quick-add script does: ensure +// osquery is present at the required version, write the secret, flags and +// certificate, and bring the service up. +func installNode(ctx context.Context, cmd *cli.Command) error { + if err := requirePrivileges(); err != nil { + return err + } + verification, err := spin("verifying node with osctrl", func() (VerifyResponse, error) { + return retrieveVerify(appConfig.OsctrlSecret, appConfig.OsquerySecretFile, appConfig.OsqueryCertFile, osctrlURLs.Verify, appConfig.Insecure) + }) + if err != nil { + return fmt.Errorf("error retrieving verification - %v", err) + } + if err := ensureOsquery(verification); err != nil { + return fmt.Errorf("error installing osquery - %v", err) + } + if err := stopOsquery(); err != nil { + return fmt.Errorf("error stopping osquery - %v", err) + } + // The secret is a credential: 0600, not the 0700 the other files get + if err := os.MkdirAll(filepath.Dir(appConfig.OsquerySecretFile), 0755); err != nil { + return fmt.Errorf("error creating the secret directory - %v", err) + } + if _, err := writeContentExists(appConfig.OsquerySecretFile, appConfig.OsctrlSecret, "secret", true, 0600); err != nil { + return err + } + log.Info().Str("path", appConfig.OsquerySecretFile).Msg("secret ready") + + if err := os.MkdirAll(filepath.Dir(appConfig.OsqueryFlagFile), 0755); err != nil { + return fmt.Errorf("error creating the flags directory - %v", err) + } + if _, err := writeContentExists(appConfig.OsqueryFlagFile, verification.Flags, "flags", true, 0700); err != nil { + return err + } + log.Info().Str("path", appConfig.OsqueryFlagFile).Msg("flags ready") + + if err := os.MkdirAll(filepath.Dir(appConfig.OsqueryCertFile), 0755); err != nil { + return fmt.Errorf("error creating the certificate directory - %v", err) + } + if _, err := writeContentExists(appConfig.OsqueryCertFile, verification.Certificate, "cert", true, 0700); err != nil { + return err + } + log.Info().Str("path", appConfig.OsqueryCertFile).Msg("certificate ready") + + if err := startOsquery(); err != nil { + return fmt.Errorf("error starting osquery - %v", err) + } + if err := enableOsquery(); err != nil { + return fmt.Errorf("error enabling osquery - %v", err) + } + log.Info().Str("environment", appConfig.Environment).Msg("node enrolled") + return nil +} + +// removeIfExists deletes a file, treating an already-absent file as success so +// that uninstall can be re-run safely. +func removeIfExists(path, name string) error { + if !checkFileExist(path) { + log.Debug().Str("path", path).Msgf("%s not present, nothing to remove", name) + return nil + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("error removing %s from %s - %v", name, path, err) + } + log.Info().Str("path", path).Msgf("%s removed", name) + return nil +} + +// uninstallNode performs natively what the osctrl quick-remove script does. It +// deliberately does NOT remove the osquery package, matching that script. +func uninstallNode(ctx context.Context, cmd *cli.Command) error { + if err := requirePrivileges(); err != nil { + return err + } + if err := stopOsquery(); err != nil { + return fmt.Errorf("error stopping osquery - %v", err) + } + for _, f := range []struct{ path, name string }{ + {appConfig.OsquerySecretFile, "secret"}, + {appConfig.OsqueryFlagFile, "flags"}, + {appConfig.OsqueryCertFile, "cert"}, + } { + if err := removeIfExists(f.path, f.name); err != nil { + return err + } + } + log.Info().Str("environment", appConfig.Environment).Msg("node removed, osquery itself was left installed") + return nil +} diff --git a/cmd/osctrld/install_test.go b/cmd/osctrld/install_test.go new file mode 100644 index 0000000..5910802 --- /dev/null +++ b/cmd/osctrld/install_test.go @@ -0,0 +1,104 @@ +//go:build !windows + +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOsqueryInstallDecision(t *testing.T) { + cases := []struct { + name string + installed string + required string + expected bool + }{ + {"not installed", "", "5.12.1", true}, + {"older must install", "5.11.0", "5.12.1", true}, + {"much older must install", "4.9.0", "5.0.0", true}, + {"equal is skipped", "5.12.1", "5.12.1", false}, + {"newer is left alone", "5.13.0", "5.12.1", false}, + {"newer major is left alone", "6.0.0", "5.12.1", false}, + {"unparseable installed is reinstalled", "5.12.x", "5.12.1", true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, osqueryInstallDecision(tt.installed, tt.required)) + }) + } +} + +func TestInstallNodeRequiresPrivileges(t *testing.T) { + prev := geteuid + t.Cleanup(func() { geteuid = prev }) + geteuid = func() int { return 501 } + + err := installNode(context.Background(), newTestCLICommand()) + require.Error(t, err) + assert.Contains(t, err.Error(), "root") +} + +func TestEnsureOsquerySkipsWhenCurrent(t *testing.T) { + recorded := fakeExec(t) + + prevVersion := osqueryVersionReader + t.Cleanup(func() { osqueryVersionReader = prevVersion }) + osqueryVersionReader = func() string { return "5.12.1" } + + require.NoError(t, ensureOsquery(VerifyResponse{OsqueryVersion: "5.12.1"})) + assert.Empty(t, *recorded, "nothing should be installed when the version already matches") +} + +func TestRemoveIfExists(t *testing.T) { + dir := t.TempDir() + present := filepath.Join(dir, "secret") + require.NoError(t, os.WriteFile(present, []byte("s3cr3t"), 0600)) + + require.NoError(t, removeIfExists(present, "secret")) + assert.NoFileExists(t, present) + + // An absent file is reported, not an error: uninstall must be idempotent + require.NoError(t, removeIfExists(filepath.Join(dir, "never-existed"), "secret")) +} + +func TestUninstallNodeRequiresPrivileges(t *testing.T) { + prev := geteuid + t.Cleanup(func() { geteuid = prev }) + geteuid = func() int { return 501 } + + err := uninstallNode(context.Background(), newTestCLICommand()) + require.Error(t, err) + assert.Contains(t, err.Error(), "root") +} + +func TestUninstallNodeRemovesTheThreeFiles(t *testing.T) { + prevUID := geteuid + t.Cleanup(func() { geteuid = prevUID }) + geteuid = func() int { return 0 } + fakeExec(t) + fakeLookPath(t, "systemctl") + + dir := t.TempDir() + prevConfig := appConfig + t.Cleanup(func() { appConfig = prevConfig }) + appConfig = Configuration{ + OsquerySecretFile: filepath.Join(dir, "osquery.secret"), + OsqueryFlagFile: filepath.Join(dir, "osquery.flags"), + OsqueryCertFile: filepath.Join(dir, "osctrl.crt"), + } + for _, p := range []string{appConfig.OsquerySecretFile, appConfig.OsqueryFlagFile, appConfig.OsqueryCertFile} { + require.NoError(t, os.WriteFile(p, []byte("x"), 0600)) + } + + require.NoError(t, uninstallNode(context.Background(), newTestCLICommand())) + + assert.NoFileExists(t, appConfig.OsquerySecretFile) + assert.NoFileExists(t, appConfig.OsqueryFlagFile) + assert.NoFileExists(t, appConfig.OsqueryCertFile) +} diff --git a/cmd/osctrld/main.go b/cmd/osctrld/main.go index b90d970..6d8ad47 100644 --- a/cmd/osctrld/main.go +++ b/cmd/osctrld/main.go @@ -79,6 +79,16 @@ func init() { Usage: "Run as a daemon, periodically syncing flags and certificate", Action: cliWrapper(serviceNode), }, + { + Name: "install", + Usage: "Enroll this node natively: install osquery if needed, write secret, flags and certificate, and start the service", + Action: cliWrapper(installNode), + }, + { + Name: "uninstall", + Usage: "Remove this node from osctrl natively: stop the service and delete secret, flags and certificate. osquery itself is left installed", + Action: cliWrapper(uninstallNode), + }, { Name: "check-config", Aliases: []string{"config-check", "verify-config"}, diff --git a/cmd/osctrld/osquery.go b/cmd/osctrld/osquery.go index fa82f60..c5c5b99 100644 --- a/cmd/osctrld/osquery.go +++ b/cmd/osctrld/osquery.go @@ -4,10 +4,44 @@ import ( "fmt" "os/exec" "runtime" + "strings" "github.com/rs/zerolog/log" ) +// Seams so tests can observe privileged commands without running them +var ( + execCommand = exec.Command + lookPath = exec.LookPath +) + +// runCommand runs one command and folds its output into any error +func runCommand(name string, args ...string) error { + log.Info().Str("command", name).Strs("args", args).Msg("running command") + out, err := execCommand(name, args...).CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s failed: %v (output: %s)", name, strings.Join(args, " "), err, string(out)) + } + return nil +} + +// runFirstAvailable runs the first candidate whose binary is present, mirroring +// the script's systemctl -> service -> init.d fallback chain. An absolute path +// is probed on disk rather than on PATH. +func runFirstAvailable(candidates [][]string) error { + for _, c := range candidates { + if strings.HasPrefix(c[0], "/") { + if !checkFileExist(c[0]) { + continue + } + } else if _, err := lookPath(c[0]); err != nil { + continue + } + return runCommand(c[0], c[1:]...) + } + return fmt.Errorf("no service manager found for osquery") +} + func osqueryRestartCommand() (string, []string) { switch runtime.GOOS { case LinuxOS: @@ -24,11 +58,76 @@ func restartOsquery() error { if cmd == "" { return fmt.Errorf("osquery restart not supported on %s", runtime.GOOS) } - log.Info().Str("command", cmd).Strs("args", args).Msg("restarting osquery") - out, err := exec.Command(cmd, args...).CombinedOutput() - if err != nil { - return fmt.Errorf("failed to restart osquery: %v (output: %s)", err, string(out)) + if err := runCommand(cmd, args...); err != nil { + return fmt.Errorf("failed to restart osquery: %v", err) } log.Info().Msg("osquery restarted successfully") return nil } + +const ( + // darwinLaunchDaemon is where the osquery plist must live to load at boot + darwinLaunchDaemon = "/Library/LaunchDaemons/io.osquery.agent.plist" + // darwinSourcePlist is the plist the osquery package ships + darwinSourcePlist = "/private/var/osquery/io.osquery.agent.plist" + // osqueryService is the service name on Linux and Windows + osqueryService = "osqueryd" +) + +// stopOsquery stops the osquery service. On macOS an unload that fails because +// the daemon was not loaded is not an error: stopped is the desired state either way. +func stopOsquery() error { + switch runtime.GOOS { + case LinuxOS: + return runFirstAvailable([][]string{ + {"systemctl", "stop", osqueryService}, + {"service", osqueryService, "stop"}, + {"/etc/init.d/" + osqueryService, "stop"}, + }) + case DarwinOS: + if err := runCommand("launchctl", "unload", darwinLaunchDaemon); err != nil { + log.Debug().Err(err).Msg("launchctl unload failed, assuming osquery was not loaded") + } + return nil + case WindowsOS: + return windowsServiceStop() + } + return fmt.Errorf("stopping osquery is not supported on %s", runtime.GOOS) +} + +// startOsquery starts the osquery service, installing the launch daemon on macOS +func startOsquery() error { + switch runtime.GOOS { + case LinuxOS: + return runFirstAvailable([][]string{ + {"systemctl", "start", osqueryService}, + {"service", osqueryService, "start"}, + {"/etc/init.d/" + osqueryService, "start"}, + }) + case DarwinOS: + if err := runCommand("cp", darwinSourcePlist, darwinLaunchDaemon); err != nil { + return err + } + return runCommand("launchctl", "load", darwinLaunchDaemon) + case WindowsOS: + return windowsServiceStart() + } + return fmt.Errorf("starting osquery is not supported on %s", runtime.GOOS) +} + +// enableOsquery makes osquery start at boot. macOS needs no separate step: a +// LaunchDaemon plist in /Library/LaunchDaemons is loaded at boot by launchd. +func enableOsquery() error { + switch runtime.GOOS { + case LinuxOS: + return runFirstAvailable([][]string{ + {"systemctl", "enable", osqueryService}, + {"update-rc.d", osqueryService, "defaults"}, + }) + case DarwinOS: + return nil + case WindowsOS: + return windowsServiceEnable() + } + return fmt.Errorf("enabling osquery is not supported on %s", runtime.GOOS) +} diff --git a/cmd/osctrld/osquery_other.go b/cmd/osctrld/osquery_other.go new file mode 100644 index 0000000..e68a9e0 --- /dev/null +++ b/cmd/osctrld/osquery_other.go @@ -0,0 +1,12 @@ +//go:build !windows + +package main + +import "fmt" + +// These exist only so the GOOS dispatch in osquery.go compiles off Windows. +// The dispatch never reaches them, because it selects them by runtime.GOOS. + +func windowsServiceStop() error { return fmt.Errorf("windows service control unavailable") } +func windowsServiceStart() error { return fmt.Errorf("windows service control unavailable") } +func windowsServiceEnable() error { return fmt.Errorf("windows service control unavailable") } diff --git a/cmd/osctrld/osquery_test.go b/cmd/osctrld/osquery_test.go index aa45e7e..96afb61 100644 --- a/cmd/osctrld/osquery_test.go +++ b/cmd/osctrld/osquery_test.go @@ -1,10 +1,12 @@ package main import ( + "os/exec" "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestOsqueryRestartCommand(t *testing.T) { @@ -21,3 +23,141 @@ func TestOsqueryRestartCommand(t *testing.T) { assert.Nil(t, args) } } + +// fakeExec records every command instead of running it. The returned commands +// succeed; use fakeExecFailing for the error path. +func fakeExec(t *testing.T) *[][]string { + t.Helper() + recorded := &[][]string{} + prev := execCommand + execCommand = func(name string, args ...string) *exec.Cmd { + *recorded = append(*recorded, append([]string{name}, args...)) + return exec.Command("true") + } + t.Cleanup(func() { execCommand = prev }) + return recorded +} + +func fakeLookPath(t *testing.T, available ...string) { + t.Helper() + set := map[string]bool{} + for _, a := range available { + set[a] = true + } + prev := lookPath + lookPath = func(file string) (string, error) { + if set[file] { + return "/usr/bin/" + file, nil + } + return "", exec.ErrNotFound + } + t.Cleanup(func() { lookPath = prev }) +} + +func TestRunCommandRecords(t *testing.T) { + recorded := fakeExec(t) + require.NoError(t, runCommand("systemctl", "stop", "osqueryd")) + assert.Equal(t, [][]string{{"systemctl", "stop", "osqueryd"}}, *recorded) +} + +func TestRunCommandReportsFailure(t *testing.T) { + prev := execCommand + t.Cleanup(func() { execCommand = prev }) + execCommand = func(name string, args ...string) *exec.Cmd { return exec.Command("false") } + + err := runCommand("systemctl", "stop", "osqueryd") + require.Error(t, err) + assert.Contains(t, err.Error(), "systemctl") +} + +func TestRunFirstAvailableSkipsMissingBinaries(t *testing.T) { + recorded := fakeExec(t) + fakeLookPath(t, "service") + + err := runFirstAvailable([][]string{ + {"systemctl", "stop", "osqueryd"}, + {"service", "osqueryd", "stop"}, + }) + require.NoError(t, err) + assert.Equal(t, [][]string{{"service", "osqueryd", "stop"}}, *recorded, + "systemctl is absent, so the service fallback must be used") +} + +func TestRunFirstAvailableNoneFound(t *testing.T) { + fakeExec(t) + fakeLookPath(t) + + err := runFirstAvailable([][]string{{"systemctl", "stop", "osqueryd"}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no service manager") +} + +func TestStopOsqueryLinux(t *testing.T) { + if runtime.GOOS != LinuxOS { + t.Skip("linux-only dispatch") + } + recorded := fakeExec(t) + fakeLookPath(t, "systemctl") + + require.NoError(t, stopOsquery()) + assert.Equal(t, [][]string{{"systemctl", "stop", "osqueryd"}}, *recorded) +} + +func TestStopOsqueryDarwin(t *testing.T) { + if runtime.GOOS != DarwinOS { + t.Skip("darwin-only dispatch") + } + recorded := fakeExec(t) + + require.NoError(t, stopOsquery()) + assert.Equal(t, [][]string{{"launchctl", "unload", darwinLaunchDaemon}}, *recorded) +} + +// TestStopOsqueryDarwinToleratesFailedUnload locks in that a failed launchctl +// unload (e.g. osquery was never loaded) is still reported as success: a +// first-time install depends on this. +func TestStopOsqueryDarwinToleratesFailedUnload(t *testing.T) { + if runtime.GOOS != DarwinOS { + t.Skip("darwin-only dispatch") + } + prev := execCommand + t.Cleanup(func() { execCommand = prev }) + execCommand = func(name string, args ...string) *exec.Cmd { return exec.Command("false") } + + require.NoError(t, stopOsquery()) +} + +func TestEnableOsqueryLinuxFallsBackToUpdateRcD(t *testing.T) { + if runtime.GOOS != LinuxOS { + t.Skip("linux-only dispatch") + } + recorded := fakeExec(t) + fakeLookPath(t, "update-rc.d") + + require.NoError(t, enableOsquery()) + assert.Equal(t, [][]string{{"update-rc.d", "osqueryd", "defaults"}}, *recorded) +} + +func TestStartOsqueryLinux(t *testing.T) { + if runtime.GOOS != LinuxOS { + t.Skip("linux-only dispatch") + } + recorded := fakeExec(t) + fakeLookPath(t, "systemctl") + + require.NoError(t, startOsquery()) + assert.Equal(t, [][]string{{"systemctl", "start", "osqueryd"}}, *recorded) +} + +func TestStartOsqueryDarwin(t *testing.T) { + if runtime.GOOS != DarwinOS { + t.Skip("darwin-only dispatch") + } + recorded := fakeExec(t) + + require.NoError(t, startOsquery()) + assert.Equal(t, [][]string{ + {"cp", darwinSourcePlist, darwinLaunchDaemon}, + {"launchctl", "load", darwinLaunchDaemon}, + }, *recorded) +} diff --git a/cmd/osctrld/osquery_windows.go b/cmd/osctrld/osquery_windows.go new file mode 100644 index 0000000..392cbc0 --- /dev/null +++ b/cmd/osctrld/osquery_windows.go @@ -0,0 +1,91 @@ +//go:build windows + +package main + +import ( + "fmt" + "time" + + "github.com/rs/zerolog/log" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +// openOsqueryService connects to the service manager and opens the osquery service. +// The caller must close both returned handles. +func openOsqueryService() (*mgr.Mgr, *mgr.Service, error) { + m, err := mgr.Connect() + if err != nil { + return nil, nil, fmt.Errorf("error connecting to the service manager - %v", err) + } + s, err := m.OpenService(osqueryService) + if err != nil { + _ = m.Disconnect() + return nil, nil, fmt.Errorf("error opening the %s service - %v", osqueryService, err) + } + return m, s, nil +} + +// windowsServiceStop stops osqueryd and waits for it to reach Stopped. A service +// that is already stopped is not an error: stopped is the desired state. +func windowsServiceStop() error { + m, s, err := openOsqueryService() + if err != nil { + log.Debug().Err(err).Msg("osquery service not present, nothing to stop") + return nil + } + defer func() { _ = m.Disconnect() }() + defer s.Close() + + status, err := s.Control(svc.Stop) + if err != nil { + log.Debug().Err(err).Msg("osquery service was not running") + return nil + } + deadline := time.Now().Add(30 * time.Second) + for status.State != svc.Stopped { + if time.Now().After(deadline) { + return fmt.Errorf("timed out waiting for %s to stop", osqueryService) + } + time.Sleep(500 * time.Millisecond) + if status, err = s.Query(); err != nil { + return fmt.Errorf("error querying %s - %v", osqueryService, err) + } + } + return nil +} + +// windowsServiceStart starts osqueryd +func windowsServiceStart() error { + m, s, err := openOsqueryService() + if err != nil { + return err + } + defer func() { _ = m.Disconnect() }() + defer s.Close() + + if err := s.Start(); err != nil { + return fmt.Errorf("error starting %s - %v", osqueryService, err) + } + return nil +} + +// windowsServiceEnable sets osqueryd to start automatically at boot +func windowsServiceEnable() error { + m, s, err := openOsqueryService() + if err != nil { + return err + } + defer func() { _ = m.Disconnect() }() + defer s.Close() + + cfg, err := s.Config() + if err != nil { + return fmt.Errorf("error reading %s configuration - %v", osqueryService, err) + } + cfg.StartType = mgr.StartAutomatic + if err := s.UpdateConfig(cfg); err != nil { + return fmt.Errorf("error setting %s to start automatically - %v", osqueryService, err) + } + return nil +} diff --git a/cmd/osctrld/packages.go b/cmd/osctrld/packages.go new file mode 100644 index 0000000..2bd2368 --- /dev/null +++ b/cmd/osctrld/packages.go @@ -0,0 +1,180 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/rs/zerolog/log" +) + +// packageFormat is the kind of osquery package a platform installs +type packageFormat string + +const ( + pkgDeb packageFormat = "deb" + pkgRPM packageFormat = "rpm" + pkgPKG packageFormat = "pkg" + pkgMSI packageFormat = "msi" +) + +// osqueryPackagesBase is the distribution point the osctrl quick-add scripts use +const osqueryPackagesBase = "https://osquery-packages.s3.amazonaws.com" + +// Go architecture names mapped onto each packaging convention. An architecture +// that is absent is an error rather than a guess, because a wrong URL would +// download a package for the wrong machine. +var ( + debArch = map[string]string{"amd64": "amd64", "arm64": "arm64"} + rpmArch = map[string]string{"amd64": "x86_64", "arm64": "aarch64"} +) + +// osqueryPackageURL builds the download URL for one osquery version +func osqueryPackageURL(format packageFormat, version, goarch string) (string, error) { + switch format { + case pkgDeb: + a, ok := debArch[goarch] + if !ok { + return "", fmt.Errorf("no deb package for architecture %s", goarch) + } + return fmt.Sprintf("%s/deb/osquery_%s-1.linux_%s.deb", osqueryPackagesBase, version, a), nil + case pkgRPM: + a, ok := rpmArch[goarch] + if !ok { + return "", fmt.Errorf("no rpm package for architecture %s", goarch) + } + return fmt.Sprintf("%s/rpm/osquery-%s-1.linux.%s.rpm", osqueryPackagesBase, version, a), nil + case pkgPKG: + return fmt.Sprintf("%s/darwin/osquery-%s.pkg", osqueryPackagesBase, version), nil + case pkgMSI: + return fmt.Sprintf("%s/windows/osquery-%s.msi", osqueryPackagesBase, version), nil + } + return "", fmt.Errorf("unknown package format %s", format) +} + +// linuxPackageFormat picks deb or rpm by probing for RPM markers, replacing the +// script's "rpm -q -f /usr/bin/rpm" exit-code trick. Markers use absolute paths so +// they resolve correctly when root is "" (production); filepath.Join("", "/path") +// yields "/path", and filepath.Join("/tmp/xyz", "/path") yields "/tmp/xyz/path" +// for test directory scoping. +func linuxPackageFormat(root string) packageFormat { + for _, marker := range []string{"/usr/bin/rpm", "/etc/redhat-release"} { + if checkFileExist(filepath.Join(root, marker)) { + return pkgRPM + } + } + return pkgDeb +} + +// packageFormatFor resolves the package format for a platform +func packageFormatFor(goos, root string) (packageFormat, error) { + switch goos { + case DarwinOS: + return pkgPKG, nil + case WindowsOS: + return pkgMSI, nil + case LinuxOS: + return linuxPackageFormat(root), nil + } + return "", fmt.Errorf("installing osquery is not supported on %s", goos) +} + +// resolveDigest picks the expected package digest. The server's value wins so a +// future osctrl release can turn verification on for every node at once; the +// configured value is the fallback until then. An empty result means there is +// nothing to verify against. +func resolveDigest(serverDigest, configDigest string) string { + if serverDigest != "" { + return serverDigest + } + return configDigest +} + +// packageURLExt returns the file extension for a package URL, using the URL's +// path rather than its raw text so a query string (e.g. a mirror's +// "?token=...") is not mistaken for part of the extension. A URL that fails to +// parse falls back to the raw-text extension rather than erroring, since a +// weird URL should not block an otherwise-working install. +func packageURLExt(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return filepath.Ext(rawURL) + } + return filepath.Ext(u.Path) +} + +// downloadPackage fetches url and verifies its SHA-256 against digest, returning +// the path of a temporary file the caller must remove. An empty digest is +// refused unless allowUnverified is set, in which case the computed digest is +// logged so an operator can record it. +func downloadPackage(url, digest string, allowUnverified, insecure bool) (string, error) { + if digest == "" && !allowUnverified { + return "", fmt.Errorf("no SHA-256 available to verify %s - set --osquery-sha256, or pass --allow-unverified to install without verification", url) + } + code, body, err := SendRequest(http.MethodGet, url, nil, map[string]string{}, insecure) + if err != nil { + return "", fmt.Errorf("error downloading %s - %v", url, err) + } + if code != http.StatusOK { + return "", fmt.Errorf("HTTP %d downloading %s", code, url) + } + sum := fmt.Sprintf("%x", sha256.Sum256(body)) + if digest == "" { + log.Warn().Str("url", url).Str("sha256", sum).Msg("installing an unverified osquery package") + } else if !strings.EqualFold(sum, digest) { + return "", fmt.Errorf("package digest mismatch for %s - expected %s, got %s", url, digest, sum) + } + f, err := os.CreateTemp("", "osquery-package-*"+packageURLExt(url)) + if err != nil { + return "", fmt.Errorf("error creating temporary file - %v", err) + } + defer f.Close() + if _, err := f.Write(body); err != nil { + _ = os.Remove(f.Name()) + return "", fmt.Errorf("error writing package to %s - %v", f.Name(), err) + } + log.Debug().Str("path", f.Name()).Int("bytes", len(body)).Msg("osquery package downloaded") + return f.Name(), nil +} + +// verifyPackageSignature runs the platform's own signature check as defence in +// depth, after the SHA-256 gate and never instead of it. deb and rpm have no +// standalone signature to verify against without importing a keyring, which is +// precisely why the digest gate is the primary control. +func verifyPackageSignature(format packageFormat, path string) error { + switch format { + case pkgPKG: + if err := runCommand("pkgutil", "--check-signature", path); err != nil { + return fmt.Errorf("package signature check failed for %s - %v", path, err) + } + case pkgMSI: + script := fmt.Sprintf("if ((Get-AuthenticodeSignature '%s').Status -ne 'Valid') { exit 1 }", path) + if err := runCommand("powershell", "-NoProfile", "-NonInteractive", "-Command", script); err != nil { + return fmt.Errorf("authenticode signature check failed for %s - %v", path, err) + } + } + return nil +} + +// installPackage verifies the package signature where the platform offers one, +// then runs the platform's installer against the downloaded file. +func installPackage(format packageFormat, path string) error { + if err := verifyPackageSignature(format, path); err != nil { + return err + } + switch format { + case pkgDeb: + return runCommand("dpkg", "-i", path) + case pkgRPM: + return runCommand("rpm", "-Uvh", path) + case pkgPKG: + return runCommand("installer", "-pkg", path, "-target", "/") + case pkgMSI: + return runCommand("msiexec", "/i", path, "/passive", "/norestart", "/qn") + } + return fmt.Errorf("unknown package format %s", format) +} diff --git a/cmd/osctrld/packages_test.go b/cmd/osctrld/packages_test.go new file mode 100644 index 0000000..205d174 --- /dev/null +++ b/cmd/osctrld/packages_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOsqueryPackageURL(t *testing.T) { + const base = "https://osquery-packages.s3.amazonaws.com" + cases := []struct { + name string + format packageFormat + version string + goarch string + expected string + }{ + {"deb amd64", pkgDeb, "5.12.1", "amd64", base + "/deb/osquery_5.12.1-1.linux_amd64.deb"}, + {"deb arm64", pkgDeb, "5.12.1", "arm64", base + "/deb/osquery_5.12.1-1.linux_arm64.deb"}, + {"rpm amd64 is x86_64", pkgRPM, "5.12.1", "amd64", base + "/rpm/osquery-5.12.1-1.linux.x86_64.rpm"}, + {"rpm arm64 is aarch64", pkgRPM, "5.12.1", "arm64", base + "/rpm/osquery-5.12.1-1.linux.aarch64.rpm"}, + {"darwin pkg is arch independent", pkgPKG, "5.12.1", "arm64", base + "/darwin/osquery-5.12.1.pkg"}, + {"windows msi is arch independent", pkgMSI, "5.12.1", "amd64", base + "/windows/osquery-5.12.1.msi"}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + got, err := osqueryPackageURL(tt.format, tt.version, tt.goarch) + require.NoError(t, err) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestOsqueryPackageURLUnsupportedArch(t *testing.T) { + _, err := osqueryPackageURL(pkgDeb, "5.12.1", "mips") + require.Error(t, err) + assert.Contains(t, err.Error(), "mips") +} + +func TestLinuxPackageFormat(t *testing.T) { + t.Run("rpm when /usr/bin/rpm exists", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "usr/bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "usr/bin/rpm"), []byte("x"), 0755)) + assert.Equal(t, pkgRPM, linuxPackageFormat(root)) + }) + + t.Run("rpm when /etc/redhat-release exists", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "etc"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "etc/redhat-release"), []byte("x"), 0644)) + assert.Equal(t, pkgRPM, linuxPackageFormat(root)) + }) + + t.Run("deb when neither marker is present", func(t *testing.T) { + assert.Equal(t, pkgDeb, linuxPackageFormat(t.TempDir())) + }) +} + +func TestPackageFormatFor(t *testing.T) { + f, err := packageFormatFor(DarwinOS, "") + require.NoError(t, err) + assert.Equal(t, pkgPKG, f) + + f, err = packageFormatFor(WindowsOS, "") + require.NoError(t, err) + assert.Equal(t, pkgMSI, f) + + f, err = packageFormatFor(LinuxOS, t.TempDir()) + require.NoError(t, err) + assert.Equal(t, pkgDeb, f) + + _, err = packageFormatFor("freebsd", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "freebsd") +} + +// linuxPackageFormat must probe ABSOLUTE paths. Planting a decoy marker reachable +// only through a CWD-relative path proves it: with relative markers the decoy is +// found and the answer flips; with absolute markers it is ignored. +func TestLinuxPackageFormatIgnoresWorkingDirectory(t *testing.T) { + hostAnswer := linuxPackageFormat("") + + decoy := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(decoy, "usr/bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(decoy, "usr/bin/rpm"), []byte("decoy"), 0755)) + t.Chdir(decoy) + + assert.Equal(t, hostAnswer, linuxPackageFormat(""), + "a marker reachable only via a relative path must not change the result") +} + +func TestResolveDigest(t *testing.T) { + assert.Equal(t, "server", resolveDigest("server", "config"), "server value must win") + assert.Equal(t, "config", resolveDigest("", "config")) + assert.Equal(t, "", resolveDigest("", "")) +} + +func TestDownloadPackageVerifies(t *testing.T) { + payload := []byte("pretend this is an osquery package") + sum := fmt.Sprintf("%x", sha256.Sum256(payload)) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(payload) + })) + defer server.Close() + + t.Run("matching digest downloads", func(t *testing.T) { + path, err := downloadPackage(server.URL+"/osquery.deb", sum, false, false) + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(path) }) + + got, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, payload, got) + }) + + t.Run("mismatched digest is refused", func(t *testing.T) { + _, err := downloadPackage(server.URL+"/osquery.deb", "00"+sum[2:], false, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "mismatch") + }) + + t.Run("missing digest is refused by default", func(t *testing.T) { + _, err := downloadPackage(server.URL+"/osquery.deb", "", false, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "--osquery-sha256") + assert.Contains(t, err.Error(), "--allow-unverified") + }) + + t.Run("missing digest proceeds when explicitly allowed", func(t *testing.T) { + path, err := downloadPackage(server.URL+"/osquery.deb", "", true, false) + require.NoError(t, err) + t.Cleanup(func() { _ = os.Remove(path) }) + assert.FileExists(t, path) + }) +} + +func TestPackageURLExt(t *testing.T) { + cases := []struct { + name string + url string + expected string + }{ + {"plain url", "https://example.com/osquery-5.12.1.msi", ".msi"}, + {"url with query string", "https://mirror.internal/osquery-5.12.1.msi?token=abc", ".msi"}, + {"url with no extension", "https://example.com/osquery-package", ""}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, packageURLExt(tt.url)) + }) + } +} + +func TestDownloadPackageHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := downloadPackage(server.URL+"/missing.deb", "", true, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "404") +} + +func TestInstallPackageCommands(t *testing.T) { + cases := []struct { + name string + format packageFormat + expected []string + }{ + {"deb", pkgDeb, []string{"dpkg", "-i", "/tmp/osquery.deb"}}, + {"rpm", pkgRPM, []string{"rpm", "-Uvh", "/tmp/osquery.deb"}}, + {"pkg", pkgPKG, []string{"installer", "-pkg", "/tmp/osquery.deb", "-target", "/"}}, + {"msi", pkgMSI, []string{"msiexec", "/i", "/tmp/osquery.deb", "/passive", "/norestart", "/qn"}}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + recorded := fakeExec(t) + require.NoError(t, installPackage(tt.format, "/tmp/osquery.deb")) + // deb and rpm record only the installer; pkg and msi record a + // signature check first, so assert on the last command either way + require.NotEmpty(t, *recorded) + assert.Equal(t, tt.expected, (*recorded)[len(*recorded)-1]) + }) + } +} + +func TestVerifyPackageSignature(t *testing.T) { + cases := []struct { + name string + format packageFormat + expected []string + }{ + {"darwin pkg uses pkgutil", pkgPKG, []string{"pkgutil", "--check-signature", "/tmp/osquery.pkg"}}, + {"windows msi uses Authenticode", pkgMSI, []string{ + "powershell", "-NoProfile", "-NonInteractive", "-Command", + "if ((Get-AuthenticodeSignature '/tmp/osquery.pkg').Status -ne 'Valid') { exit 1 }", + }}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + recorded := fakeExec(t) + require.NoError(t, verifyPackageSignature(tt.format, "/tmp/osquery.pkg")) + assert.Equal(t, [][]string{tt.expected}, *recorded) + }) + } +} + +// deb has no standalone signature to check, which is exactly why the SHA-256 +// gate in Task 5 is the primary control rather than a nicety. +func TestVerifyPackageSignatureSkipsDebAndRPM(t *testing.T) { + for _, format := range []packageFormat{pkgDeb, pkgRPM} { + recorded := fakeExec(t) + require.NoError(t, verifyPackageSignature(format, "/tmp/osquery.deb")) + assert.Empty(t, *recorded) + } +} + +func TestInstallPackageStopsOnBadSignature(t *testing.T) { + prev := execCommand + t.Cleanup(func() { execCommand = prev }) + calls := 0 + execCommand = func(name string, args ...string) *exec.Cmd { + calls++ + return exec.Command("false") + } + + err := installPackage(pkgPKG, "/tmp/osquery.pkg") + require.Error(t, err) + assert.Contains(t, err.Error(), "signature") + assert.Equal(t, 1, calls, "a failed signature check must not reach the installer") +} diff --git a/cmd/osctrld/privileges.go b/cmd/osctrld/privileges.go new file mode 100644 index 0000000..5963184 --- /dev/null +++ b/cmd/osctrld/privileges.go @@ -0,0 +1,20 @@ +//go:build !windows + +package main + +import ( + "fmt" + "os" +) + +// geteuid is a seam so the privilege gate can be exercised without running tests as root +var geteuid = os.Geteuid + +// requirePrivileges fails unless this process can write system paths and manage +// services. osctrld never escalates on its own: the operator runs it under sudo. +func requirePrivileges() error { + if geteuid() != 0 { + return fmt.Errorf("this command needs root privileges, re-run it with sudo") + } + return nil +} diff --git a/cmd/osctrld/privileges_test.go b/cmd/osctrld/privileges_test.go new file mode 100644 index 0000000..ca21c10 --- /dev/null +++ b/cmd/osctrld/privileges_test.go @@ -0,0 +1,29 @@ +//go:build !windows + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequirePrivilegesAsRoot(t *testing.T) { + prev := geteuid + t.Cleanup(func() { geteuid = prev }) + geteuid = func() int { return 0 } + + require.NoError(t, requirePrivileges()) +} + +func TestRequirePrivilegesUnprivileged(t *testing.T) { + prev := geteuid + t.Cleanup(func() { geteuid = prev }) + geteuid = func() int { return 501 } + + err := requirePrivileges() + require.Error(t, err) + assert.Contains(t, err.Error(), "root") + assert.Contains(t, err.Error(), "sudo", "the error must tell the operator how to proceed") +} diff --git a/cmd/osctrld/privileges_windows.go b/cmd/osctrld/privileges_windows.go new file mode 100644 index 0000000..5c3bf9a --- /dev/null +++ b/cmd/osctrld/privileges_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package main + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// isAdmin is a seam mirroring geteuid on unix +var isAdmin = currentProcessIsAdmin + +// currentProcessIsAdmin reports whether the effective token is a member of the +// built-in Administrators group. The zero Token asks about the current thread. +func currentProcessIsAdmin() bool { + sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return false + } + member, err := windows.Token(0).IsMember(sid) + return err == nil && member +} + +// requirePrivileges fails unless this process holds an elevated token. +func requirePrivileges() error { + if !isAdmin() { + return fmt.Errorf("this command needs Administrator privileges, re-run it from an elevated prompt") + } + return nil +} diff --git a/go.mod b/go.mod index 5ef8d34..9442e9e 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,6 @@ require ( github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) @@ -40,5 +39,6 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.45.0 gotest.tools v2.2.0+incompatible )