diff --git a/README.md b/README.md index 1ed017d..b8de991 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ It is designed to run close to osquery as a small operational companion: it can - 🔎 Verify local flags, certificates, and secrets against the server. - ⚡ Run as a daemon with a configurable sync interval and jitter. - 🔁 Restart osquery when managed files or extensions change. -- 🧩 Deploy osquery extensions from osctrl extension manifests, when the osctrl server serves them. +- 🧩 Deploy osquery extensions from osctrl extension lists, when the osctrl server serves them. - ⚙️ Use YAML or JSON configuration files, CLI flags, or environment variables. - 📋 Emit human-readable or JSON structured logs. - 🖥️ Run on Linux, macOS, and Windows. @@ -84,7 +84,7 @@ osctrld is a Go CLI built around a small set of commands: - `check-config` validates a configuration file and exits. - `default-config` prints a generated YAML configuration template. -The daemon mode performs a sync cycle on a configurable interval. During each cycle, osctrld retrieves flags, certificates, and extension manifests from osctrl, writes changed content to disk, and restarts osquery through the operating system service manager when a managed artifact changes. +The daemon mode performs a sync cycle on a configurable interval. During each cycle, osctrld retrieves flags, certificates, and extension lists from osctrl, writes changed content to disk, and restarts osquery through the operating system service manager when a managed artifact changes. Configuration comes from CLI flags, environment variables, or the `osctrld` section of a YAML or JSON file. YAML is the default format for new deployments. Note that supplying a configuration file replaces the entire effective configuration, so a config file takes precedence over flags and environment variables for every field, not only the ones it sets. Runtime defaults are applied before commands run, and wrapped commands validate the effective configuration before doing work. @@ -223,6 +223,62 @@ check interrupted by a stop, crash or reboot is finished on the next sync. This along with the last known host UUID (used when the lookup fails), is kept next to the flagfile, in `.osctrld-state.json`. +### Extensions + +Extensions are synced after flags and certificates, with their own restart and check, +so a bad extension never rolls back good flags (two restarts only when both changed). +The extensions request carries the host UUID and platform/architecture, and the +outcome of the last extensions change, which is sent once. + +- **Verified.** Each extension is downloaded from the configured osctrl server with + `POST //osctrld-extension` and `{secret, sha256}` (never from a URL in the + list) into a temp file in `extensionsDir`, and its size and SHA-256 + are checked before it is renamed into place as `.ext` (`.exe` on Windows). +- **Root-owned.** Files are owned by root at `0755` (an Administrators/SYSTEM-only write + ACL on Windows), and `extensionsDir` is kept root-owned and not group/world-writable. + Symlinks are never written or removed through. +- **Autoload file.** osctrld writes the file named by `--extensions_autoload` in the + flagfile, or osquery's default (`/etc/osquery/extensions.load`, + `/private/var/osquery/extensions.load`, `C:\Program Files\osquery\extensions.load`). + It manages only its own lines; lines an operator added, and files an operator placed + in `extensionsDir`, are kept, even when osctrl assigns no extensions. The file is + checked every sync: if osctrld's lines are missing, or the flags moved it, the lines + are written again (and taken out of the old file), and osquery is restarted and checked. + Since the path comes from the flags, which environment admins can edit, osctrld only + writes a `.load` file directly in osquery's base directory (`osquery` in the + configuration; the defaults above qualify), and never one that is a symlink, the + flagfile, the secret or certificate file, one of osctrld's state files, inside + `extensionsDir`, or in a directory not owned by root or writable by others. On a + refused path it changes nothing that sync and reports the error for every served + extension, once per refused path. An existing file keeps its mode, minus group/world + write. +- **Removal.** Extensions osctrld installed that are no longer assigned are removed, + with their autoload lines, and reported as `removed`. A removal that fails (the file + cannot be moved aside, the autoload file cannot be written, or `osqueryd` does not + stay up without it) leaves the extension installed and is reported as `kept`, so + osctrl can show that a revoked extension is still on the host. +- **Process check and rollback.** After the restart, osctrld waits up to 60 seconds for + `osqueryd`, then up to 60 seconds more for a process running each new extension's + installed path. If `osqueryd` does not stay up, the previous extensions are restored + and osquery is restarted. If only some extensions do not run, just those are removed + and osquery is restarted once. Failed content is reported as `rejected` and not + installed again until osctrl serves a different SHA-256; meanwhile the last good + version, if any, stays installed. A removal that keeps `osqueryd` down (for example + while the flags still `--extensions_require` it) is undone and not retried until + osctrl serves that extension again; until then it is reported `kept` on every sync, + and once served again, as installed, it is reported `ok`. + Statuses are sent on the next extensions request, only the newest per extension; + they are dropped only when osctrl answers 400 or 413, and kept for the next request + on any other error (for example 403 after a secret rotation). With `--disable_extensions` + in the flags, extensions are still installed and reported `ok` with a note, without + the process check. +- **Windows.** A running executable cannot be overwritten or deleted, so a replaced or + removed extension is renamed aside (`.prev`) and deleted after osquery restarted. + +This state, including a check interrupted by a stop, crash or reboot (finished on the +next sync, restarting osquery first if that had not happened yet), is kept in +`.osctrld-extensions-state.json`. + ## Native enrollment `osctrld install` does everything the osctrl quick-add script does, without a shell: diff --git a/cmd/osctrld/actions.go b/cmd/osctrld/actions.go index 4ca1b89..0ab701d 100644 --- a/cmd/osctrld/actions.go +++ b/cmd/osctrld/actions.go @@ -70,15 +70,37 @@ type VerifyResponse struct { OsquerySHA256 string `json:"osquery_sha256"` } -// ExtensionEntry represents a single extension from the manifest +// ExtensionEntry represents a single extension from the manifest. URL is +// informational only: osctrld builds the download URL from its own osctrl base. type ExtensionEntry struct { - Name string `json:"name"` - URL string `json:"url"` + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + URL string `json:"url"` +} + +// ExtensionStatus reports the outcome for one extension; Result is "ok" or "rejected" +type ExtensionStatus struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` + Result string `json:"result"` + Error string `json:"error,omitempty"` + At time.Time `json:"at"` } // ExtensionsRequest to retrieve extension manifest type ExtensionsRequest struct { + OsctrlSecret string `json:"secret"` + UUID string `json:"uuid,omitempty"` + Platform string `json:"platform"` + Arch string `json:"arch"` + Status []ExtensionStatus `json:"status,omitempty"` +} + +// ExtensionDownloadRequest to download one extension by its SHA-256 +type ExtensionDownloadRequest struct { OsctrlSecret string `json:"secret"` + SHA256 string `json:"sha256"` } // Function to action on enroll command diff --git a/cmd/osctrld/extensions.go b/cmd/osctrld/extensions.go index 25a0a66..f6feda2 100644 --- a/cmd/osctrld/extensions.go +++ b/cmd/osctrld/extensions.go @@ -1,21 +1,41 @@ package main import ( + "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "io" + "io/fs" "net/http" "os" "path/filepath" - "strings" + "regexp" "github.com/rs/zerolog/log" ) -func retrieveExtensionManifest(osctrlSecret, url string, insecure bool) ([]ExtensionEntry, error) { - reqData := ExtensionsRequest{OsctrlSecret: osctrlSecret} - body, err := genericRetrieve(url, insecure, reqData) - // osctrl does not serve osctrld-extensions yet. Treat its 404 as an empty +// maxExtensionSize caps any single extension download +const maxExtensionSize = 64 << 20 + +var ( + extensionNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + extensionSHA256Re = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +// setOwner hands a file or directory to the administrator before osquery may +// load it. It is a seam so tests run without root. +var setOwner = setOwnerRoot + +// extensionFileSafe reports whether an existing extension file is owned and +// protected so only an administrator can have written it. A seam for tests. +var extensionFileSafe = adminOwnedFile + +func retrieveExtensionManifest(req ExtensionsRequest, url string, insecure bool) ([]ExtensionEntry, error) { + body, err := genericRetrieve(url, insecure, req) + // An osctrl without extensions support answers 404. Treat it as an empty // manifest so the daemon does not log a failure every cycle; a wrong URL or // environment still fails loudly earlier in the cycle, on flags and cert. var statusErr *httpStatusError @@ -24,7 +44,7 @@ func retrieveExtensionManifest(osctrlSecret, url string, insecure bool) ([]Exten return nil, nil } if err != nil { - return nil, fmt.Errorf("error retrieving extension manifest - %v", err) + return nil, fmt.Errorf("error retrieving extension manifest - %w", err) } var entries []ExtensionEntry if err := json.Unmarshal(body, &entries); err != nil { @@ -33,53 +53,170 @@ func retrieveExtensionManifest(osctrlSecret, url string, insecure bool) ([]Exten return entries, nil } -func downloadExtension(url, destPath string, insecure bool) (bool, error) { - code, body, err := SendRequest(http.MethodGet, url, nil, map[string]string{}, insecure) +// extensionFileName turns an extension name osctrl serves into the file osquery +// autoloads. The name comes from the network and the file is written as root, so only a short +// plain name is accepted, never a path. +func extensionFileName(name, goos string) (string, error) { + if !extensionNameRe.MatchString(name) { + return "", fmt.Errorf("invalid extension name %q", name) + } + if goos == WindowsOS { + return name + ".exe", nil + } + return name + ".ext", nil +} + +// fileSHA256 hashes a regular file on disk; anything else (a FIFO would block +// the open) is refused +func fileSHA256(path string) (string, error) { + info, err := os.Lstat(path) if err != nil { - return false, fmt.Errorf("error downloading extension - %v", err) + return "", err } - if code != http.StatusOK { - return false, fmt.Errorf("HTTP %d downloading extension", code) + if !info.Mode().IsRegular() { + return "", fmt.Errorf("%s is not a regular file", path) } - changed, err := writeContentExists(destPath, string(body), filepath.Base(destPath), true, 0755) + f, err := os.Open(path) if err != nil { - return false, err + return "", err } - return changed, nil + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil } -func syncExtensions() (bool, error) { - log.Info().Msg("syncing extensions") - manifest, err := retrieveExtensionManifest(appConfig.OsctrlSecret, osctrlURLs.Extensions, appConfig.Insecure) +// installExtension places one extension in dir, returning its path and whether +// it was written. The download URL is built from the configured osctrl base and +// environment (osctrlURLs.URL); e.URL is never used, so a manifest cannot send +// osctrld, or its secret, to another host. The file is streamed into a temp file +// in dir, verified against e.Size and e.SHA256, owned by the administrator and +// only then renamed over the target. +func installExtension(dir string, e ExtensionEntry, goos string, secret string, insecure bool) (path string, changed bool, err error) { + fileName, err := extensionFileName(e.Name, goos) if err != nil { - return false, err - } - if len(manifest) == 0 { - log.Debug().Msg("no extensions in manifest") - return false, nil - } - if err := os.MkdirAll(appConfig.ExtensionsDir, 0755); err != nil { - return false, fmt.Errorf("error creating extensions directory - %v", err) - } - anyChanged := false - for _, ext := range manifest { - // The name comes from the network and the file is written as root, so - // only a plain file name, never a path, may come through - if ext.Name == "" || ext.Name == "." || ext.Name == ".." || ext.Name != filepath.Base(ext.Name) || strings.ContainsAny(ext.Name, `/\`) { - log.Error().Str("name", ext.Name).Msg("refusing extension whose name is not a plain file name") - continue + return "", false, err + } + if !extensionSHA256Re.MatchString(e.SHA256) { + return "", false, fmt.Errorf("extension %s: invalid sha256 %q", e.Name, e.SHA256) + } + if e.Size <= 0 || e.Size > maxExtensionSize { + return "", false, fmt.Errorf("extension %s: size %d outside 1..%d", e.Name, e.Size, maxExtensionSize) + } + path = filepath.Join(dir, fileName) + info, err := os.Lstat(path) + switch { + case err == nil && !info.Mode().IsRegular(): + return "", false, fmt.Errorf("extension %s: %s is not a regular file, refusing to replace it", e.Name, path) + case err == nil: + // The hash is public, so matching bytes alone prove nothing about who + // can rewrite the file; anything not safely owned is replaced below + if sum, err := fileSHA256(path); err == nil && sum == e.SHA256 && extensionFileSafe(path) { + return path, false, nil } - destPath := filepath.Join(appConfig.ExtensionsDir, ext.Name) - log.Debug().Str("name", ext.Name).Str("url", ext.URL).Msg("downloading extension") - changed, err := downloadExtension(ext.URL, destPath, appConfig.Insecure) + case !os.IsNotExist(err): + return "", false, fmt.Errorf("extension %s: %v", e.Name, err) + } + + reqBody, err := json.Marshal(ExtensionDownloadRequest{OsctrlSecret: secret, SHA256: e.SHA256}) + if err != nil { + return "", false, err + } + url := fmt.Sprintf(OsctrlURLExtension, osctrlURLs.URL) + resp, err := SendRequestStream(http.MethodPost, url, bytes.NewReader(reqBody), map[string]string{}, insecure) + if err != nil { + return "", false, fmt.Errorf("extension %s: error downloading - %v", e.Name, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", false, fmt.Errorf("extension %s: HTTP %d downloading", e.Name, resp.StatusCode) + } + + tmp, err := os.CreateTemp(dir, "."+fileName+".tmp-*") + if err != nil { + return "", false, fmt.Errorf("extension %s: %v", e.Name, err) + } + defer func() { if err != nil { - log.Error().Err(err).Str("name", ext.Name).Msg("failed to download extension") - continue + _ = tmp.Close() + _ = os.Remove(tmp.Name()) } - if changed { - log.Info().Str("name", ext.Name).Str("path", destPath).Msg("extension updated") - anyChanged = true + }() + h := sha256.New() + // One byte past the size is enough to tell an oversized body + n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, e.Size+1)) + if err != nil { + return "", false, fmt.Errorf("extension %s: error downloading - %v", e.Name, err) + } + if n != e.Size { + return "", false, fmt.Errorf("extension %s: size mismatch - expected %d, got %d or more", e.Name, e.Size, n) + } + if sum := hex.EncodeToString(h.Sum(nil)); sum != e.SHA256 { + return "", false, fmt.Errorf("extension %s: sha256 mismatch - expected %s, got %s", e.Name, e.SHA256, sum) + } + if err = tmp.Close(); err != nil { + return "", false, fmt.Errorf("extension %s: %v", e.Name, err) + } + if err = setOwner(tmp.Name()); err != nil { + return "", false, fmt.Errorf("extension %s: error setting owner - %v", e.Name, err) + } + if err = os.Chmod(tmp.Name(), 0755); err != nil { + return "", false, fmt.Errorf("extension %s: %v", e.Name, err) + } + if err = os.Rename(tmp.Name(), path); err != nil { + return "", false, fmt.Errorf("extension %s: %v", e.Name, err) + } + return path, true, nil +} + +// ensureExtensionsDir makes dir an administrator-owned 0755 directory. A +// symlink is refused, since osctrld writes into it as root. +func ensureExtensionsDir(dir string) error { + // Clean drops a trailing slash, which would make Lstat follow a symlink + dir = filepath.Clean(dir) + info, err := os.Lstat(dir) + if os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("error creating extensions directory - %v", err) } + info, err = os.Lstat(dir) + } + if err != nil { + return fmt.Errorf("error checking extensions directory - %v", err) + } + // Not IsDir: a Windows junction reports ModeDir with ModeIrregular + if info.Mode().Type() != fs.ModeDir { + return fmt.Errorf("extensions directory %s is not a directory, refusing to use it", dir) + } + if err := setOwner(dir); err != nil { + return fmt.Errorf("error setting owner of %s - %v", dir, err) + } + // Repairs group/world-writable modes; MkdirAll's mode is also subject to umask + if err := os.Chmod(dir, 0755); err != nil { + return fmt.Errorf("error setting mode on %s - %v", dir, err) + } + return nil +} + +// removeManagedFile removes one file osctrld placed in dir. Anything that is +// not a regular file directly in dir is refused; an absent file is not an error. +func removeManagedFile(dir, fileName string) error { + dir = filepath.Clean(dir) + path := filepath.Join(dir, fileName) + if filepath.Dir(path) != dir { + return fmt.Errorf("refusing to remove %q, it is not a file in %s", fileName, dir) + } + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("refusing to remove %s, it is not a regular file", path) } - return anyChanged, nil + return os.Remove(path) } diff --git a/cmd/osctrld/extensions_test.go b/cmd/osctrld/extensions_test.go index 3bd4032..33f05c7 100644 --- a/cmd/osctrld/extensions_test.go +++ b/cmd/osctrld/extensions_test.go @@ -6,200 +6,401 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestRetrieveExtensionManifest_Success(t *testing.T) { - manifest := []ExtensionEntry{ - {Name: "test_ext.ext", URL: "http://example.com/test_ext.ext"}, - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(manifest) - })) - defer server.Close() +const testExtSecret = "test-secret" - entries, err := retrieveExtensionManifest("test-secret", server.URL, false) - assert.NoError(t, err) - require.Len(t, entries, 1) - assert.Equal(t, "test_ext.ext", entries[0].Name) +var testExtBody = []byte("#!/bin/sh\necho extension\n") + +// fakeSetOwner records the paths handed to setOwner instead of chowning them, +// so the installer runs without root +func fakeSetOwner(t *testing.T) *[]string { + t.Helper() + recorded := &[]string{} + prev := setOwner + setOwner = func(path string) error { + *recorded = append(*recorded, path) + return nil + } + t.Cleanup(func() { setOwner = prev }) + return recorded } -func TestRetrieveExtensionManifest_Empty(t *testing.T) { +// extensionServer serves body at the osctrld download path for env "dev" and +// points the configured osctrl base at it. It fails the test on anything but a +// POST to /dev/osctrld-extension carrying the secret and the SHA-256 of +// testExtBody, which every caller's entry names. The returned counter is the number of requests. +func extensionServer(t *testing.T, body []byte, status int) *int { + t.Helper() + requests := new(int) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode([]ExtensionEntry{}) + *requests++ + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/dev/osctrld-extension", r.URL.Path, "the SHA-256 travels in the body, not the path") + var req map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Equal(t, map[string]string{"secret": testExtSecret, "sha256": sha256Hex(string(testExtBody))}, req) + w.WriteHeader(status) + _, _ = w.Write(body) })) - defer server.Close() + t.Cleanup(server.Close) + prev := osctrlURLs + t.Cleanup(func() { osctrlURLs = prev }) + osctrlURLs = genURLs(server.URL, "dev", false) + return requests +} - entries, err := retrieveExtensionManifest("test-secret", server.URL, false) - assert.NoError(t, err) - assert.Empty(t, entries) +// fakeFileSafe decides whether an existing file counts as safely owned, since +// tests do not run as root +func fakeFileSafe(t *testing.T, safe bool) { + t.Helper() + prev := extensionFileSafe + extensionFileSafe = func(string) bool { return safe } + t.Cleanup(func() { extensionFileSafe = prev }) } -func TestRetrieveExtensionManifest_ServerError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("error")) - })) - defer server.Close() +func testEntry(body []byte) ExtensionEntry { + return ExtensionEntry{Name: "osquery_exec", SHA256: sha256Hex(string(body)), Size: int64(len(body))} +} - _, err := retrieveExtensionManifest("test-secret", server.URL, false) - assert.Error(t, err) +// tempLeftovers lists any temporary download left behind in dir +func tempLeftovers(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + var left []string + for _, e := range entries { + if strings.Contains(e.Name(), ".tmp") { + left = append(left, e.Name()) + } + } + return left } -func TestDownloadExtension_Success(t *testing.T) { - binaryContent := []byte("#!/bin/sh\necho extension") - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(binaryContent) - })) - defer server.Close() +func TestExtensionFileName(t *testing.T) { + tests := []struct { + name, goos, want string + wantErr bool + }{ + {"osquery_exec", "linux", "osquery_exec.ext", false}, + {"osquery_exec", "darwin", "osquery_exec.ext", false}, + {"osquery_exec", "windows", "osquery_exec.exe", false}, + {"my-ext_2", "linux", "my-ext_2.ext", false}, + {strings.Repeat("a", 64), "linux", strings.Repeat("a", 64) + ".ext", false}, + {"../x", "linux", "", true}, + {"a/b", "linux", "", true}, + {`a\b`, "windows", "", true}, + {"", "linux", "", true}, + {"..", "linux", "", true}, + {"a.ext", "linux", "", true}, + {strings.Repeat("a", 65), "linux", "", true}, + } + for _, tt := range tests { + t.Run(tt.name+"/"+tt.goos, func(t *testing.T) { + got, err := extensionFileName(tt.name, tt.goos) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} +func TestInstallExtensionVerifiesHash(t *testing.T) { + owned := fakeSetOwner(t) + requests := extensionServer(t, testExtBody, http.StatusOK) dir := t.TempDir() - path := filepath.Join(dir, "test_ext.ext") - changed, err := downloadExtension(server.URL, path, false) - assert.NoError(t, err) + path, changed, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + require.NoError(t, err) assert.True(t, changed) + assert.Equal(t, 1, *requests) + assert.Equal(t, filepath.Join(dir, "osquery_exec.ext"), path) content, err := os.ReadFile(path) require.NoError(t, err) - assert.Equal(t, binaryContent, content) - + assert.Equal(t, testExtBody, content) info, err := os.Stat(path) require.NoError(t, err) assert.Equal(t, os.FileMode(0755), info.Mode().Perm()) + + require.Len(t, *owned, 1) + assert.Equal(t, dir, filepath.Dir((*owned)[0])) + assert.NotEqual(t, path, (*owned)[0], "ownership is set on the temp file, before it replaces the target") + assert.Empty(t, tempLeftovers(t, dir)) } -func TestDownloadExtension_NoChange(t *testing.T) { - binaryContent := []byte("#!/bin/sh\necho extension") - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(binaryContent) - })) - defer server.Close() +func TestInstallExtensionRejectsMismatch(t *testing.T) { + fakeSetOwner(t) + // Same size, so only the hash differs + tampered := append([]byte{}, testExtBody...) + tampered[0] ^= 0xff + extensionServer(t, tampered, http.StatusOK) + dir := t.TempDir() + entry := testEntry(testExtBody) + + _, changed, err := installExtension(dir, entry, "linux", testExtSecret, false) + assert.Error(t, err) + assert.False(t, changed) + assert.NoFileExists(t, filepath.Join(dir, "osquery_exec.ext")) + assert.Empty(t, tempLeftovers(t, dir)) +} +func TestInstallExtensionRejectsOversize(t *testing.T) { + fakeSetOwner(t) + extensionServer(t, append(append([]byte{}, testExtBody...), []byte("more bytes")...), http.StatusOK) dir := t.TempDir() - path := filepath.Join(dir, "test_ext.ext") - require.NoError(t, os.WriteFile(path, binaryContent, 0755)) - changed, err := downloadExtension(server.URL, path, false) - assert.NoError(t, err) - assert.False(t, changed, "same content should not report changed") + _, _, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + assert.Error(t, err) + assert.NoFileExists(t, filepath.Join(dir, "osquery_exec.ext")) + assert.Empty(t, tempLeftovers(t, dir)) +} - 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 TestInstallExtensionRejectsBadEntry(t *testing.T) { + fakeSetOwner(t) + requests := extensionServer(t, testExtBody, http.StatusOK) + dir := t.TempDir() + good := testEntry(testExtBody) + for name, e := range map[string]ExtensionEntry{ + "zero size": {Name: good.Name, SHA256: good.SHA256, Size: 0}, + "over 64 MiB": {Name: good.Name, SHA256: good.SHA256, Size: 64<<20 + 1}, + "short sha": {Name: good.Name, SHA256: "abc", Size: good.Size}, + "sha with path": {Name: good.Name, SHA256: "../" + good.SHA256[3:], Size: good.Size}, + "bad name": {Name: "../x", SHA256: good.SHA256, Size: good.Size}, + } { + _, _, err := installExtension(dir, e, "linux", testExtSecret, false) + assert.Error(t, err, name) + } + assert.Equal(t, 0, *requests) } -func TestSyncExtensions_Success(t *testing.T) { - binaryContent := []byte("#!/bin/sh\necho extension") +func TestInstallExtensionUnchanged(t *testing.T) { + fakeSetOwner(t) + fakeFileSafe(t, true) + requests := extensionServer(t, testExtBody, http.StatusOK) + dir := t.TempDir() + target := filepath.Join(dir, "osquery_exec.ext") + require.NoError(t, os.WriteFile(target, testExtBody, 0755)) - mux := http.NewServeMux() - mux.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) { - manifest := []ExtensionEntry{ - {Name: "test_ext.ext", URL: "http://" + r.Host + "/binary"}, - } - _ = json.NewEncoder(w).Encode(manifest) - }) - mux.HandleFunc("/binary", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(binaryContent) - }) - server := httptest.NewServer(mux) - defer server.Close() + path, changed, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + require.NoError(t, err) + assert.False(t, changed) + assert.Equal(t, target, path) + assert.Equal(t, 0, *requests) +} +// The published bytes are no secret: a file with the right hash but the wrong +// owner or mode could be rewritten later by whoever owns it, so it is replaced +// with a fresh, root-owned download rather than trusted +func TestInstallExtensionReplacesUnsafeMatchingFile(t *testing.T) { + owned := fakeSetOwner(t) + fakeFileSafe(t, false) + requests := extensionServer(t, testExtBody, http.StatusOK) dir := t.TempDir() - appConfig.OsctrlSecret = "test-secret" - appConfig.ExtensionsDir = dir - appConfig.Insecure = false - osctrlURLs.Extensions = server.URL + "/manifest" + target := filepath.Join(dir, "osquery_exec.ext") + require.NoError(t, os.WriteFile(target, testExtBody, 0644)) + require.NoError(t, os.Chmod(target, 0777)) + before, err := os.Stat(target) + require.NoError(t, err) - changed, err := syncExtensions() - assert.NoError(t, err) + path, changed, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + require.NoError(t, err) assert.True(t, changed) - - content, err := os.ReadFile(filepath.Join(dir, "test_ext.ext")) + assert.Equal(t, target, path) + assert.Equal(t, 1, *requests) + after, err := os.Stat(target) require.NoError(t, err) - assert.Equal(t, binaryContent, content) + assert.Equal(t, os.FileMode(0755), after.Mode().Perm()) + assert.False(t, os.SameFile(before, after), "a new inode defeats any write handle still open on the old one") + require.Len(t, *owned, 1) + assert.NotEqual(t, target, (*owned)[0]) + assert.Empty(t, tempLeftovers(t, dir)) } -func TestSyncExtensions_EmptyManifest(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode([]ExtensionEntry{}) - })) - defer server.Close() - - appConfig.OsctrlSecret = "test-secret" - appConfig.Insecure = false - osctrlURLs.Extensions = server.URL +func TestInstallExtensionReplacesDifferentFile(t *testing.T) { + fakeSetOwner(t) + extensionServer(t, testExtBody, http.StatusOK) + dir := t.TempDir() + target := filepath.Join(dir, "osquery_exec.ext") + require.NoError(t, os.WriteFile(target, []byte("old version"), 0700)) - changed, err := syncExtensions() - assert.NoError(t, err) - assert.False(t, changed) + _, changed, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + require.NoError(t, err) + assert.True(t, changed) + content, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, testExtBody, content) } -// osctrl does not serve osctrld-extensions yet, so the daemon must treat a 404 -// as an empty manifest rather than log a sync failure on every cycle. -func TestRetrieveExtensionManifest_NotFoundIsEmpty(t *testing.T) { - server := httptest.NewServer(http.NotFoundHandler()) - defer server.Close() +func TestInstallExtensionRefusesSymlink(t *testing.T) { + fakeSetOwner(t) + requests := extensionServer(t, testExtBody, http.StatusOK) + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "victim") + require.NoError(t, os.WriteFile(outside, []byte("do not touch"), 0644)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "osquery_exec.ext"))) - entries, err := retrieveExtensionManifest("test-secret", server.URL, false) - assert.NoError(t, err) - assert.Empty(t, entries) + _, _, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + assert.Error(t, err) + assert.Equal(t, 0, *requests) + content, err := os.ReadFile(outside) + require.NoError(t, err) + assert.Equal(t, []byte("do not touch"), content) + info, err := os.Stat(outside) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) } -func TestSyncExtensions_ServerWithoutExtensionsEndpoint(t *testing.T) { - server := httptest.NewServer(http.NotFoundHandler()) - defer server.Close() +func TestInstallExtensionDownload404(t *testing.T) { + fakeSetOwner(t) + extensionServer(t, []byte("not found"), http.StatusNotFound) + dir := t.TempDir() - prevConfig, prevURLs := appConfig, osctrlURLs - t.Cleanup(func() { appConfig, osctrlURLs = prevConfig, prevURLs }) - appConfig.OsctrlSecret = "test-secret" - appConfig.ExtensionsDir = t.TempDir() - osctrlURLs.Extensions = server.URL + _, _, err := installExtension(dir, testEntry(testExtBody), "linux", testExtSecret, false) + require.Error(t, err) + assert.Contains(t, err.Error(), "osquery_exec") + assert.NoFileExists(t, filepath.Join(dir, "osquery_exec.ext")) + assert.Empty(t, tempLeftovers(t, dir)) +} - changed, err := syncExtensions() - assert.NoError(t, err) - assert.False(t, changed) +// The list comes from the network: its url must never decide where osctrld +// connects, or a tampered list could send the secret and the download elsewhere +func TestInstallExtensionIgnoresEntryURL(t *testing.T) { + fakeSetOwner(t) + requests := extensionServer(t, testExtBody, http.StatusOK) + otherRequests := 0 + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + otherRequests++ + _, _ = w.Write(testExtBody) + })) + defer other.Close() + dir := t.TempDir() + entry := testEntry(testExtBody) + entry.URL = other.URL + "/dev/osctrld-extension" + + _, changed, err := installExtension(dir, entry, "linux", testExtSecret, false) + require.NoError(t, err) + assert.True(t, changed) + assert.Equal(t, 1, *requests) + assert.Equal(t, 0, otherRequests) } -// The manifest comes over the network, and osctrld writes as root: an entry -// name must never place a file outside the extensions directory. -func TestSyncExtensions_RejectsNamesOutsideTheDirectory(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/manifest", func(w http.ResponseWriter, r *http.Request) { - bin := "http://" + r.Host + "/binary" - _ = json.NewEncoder(w).Encode([]ExtensionEntry{ - {Name: "../escape.ext", URL: bin}, - {Name: "sub/nested.ext", URL: bin}, - {Name: "..", URL: bin}, - {Name: "", URL: bin}, - {Name: "good.ext", URL: bin}, - }) +func TestEnsureExtensionsDir(t *testing.T) { + t.Run("missing is created 0755", func(t *testing.T) { + owned := fakeSetOwner(t) + dir := filepath.Join(t.TempDir(), "extensions") + require.NoError(t, ensureExtensionsDir(dir+"/")) + info, err := os.Stat(dir) + require.NoError(t, err) + assert.True(t, info.IsDir()) + assert.Equal(t, os.FileMode(0755), info.Mode().Perm()) + assert.Equal(t, []string{dir}, *owned) }) - mux.HandleFunc("/binary", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("bin")) + t.Run("world writable is repaired", func(t *testing.T) { + fakeSetOwner(t) + dir := filepath.Join(t.TempDir(), "extensions") + require.NoError(t, os.Mkdir(dir, 0755)) + require.NoError(t, os.Chmod(dir, 0777)) + require.NoError(t, ensureExtensionsDir(dir)) + info, err := os.Stat(dir) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0755), info.Mode().Perm()) }) - server := httptest.NewServer(mux) - defer server.Close() + t.Run("symlink is refused", func(t *testing.T) { + owned := fakeSetOwner(t) + linkTarget := t.TempDir() + require.NoError(t, os.Chmod(linkTarget, 0777)) + dir := filepath.Join(t.TempDir(), "extensions") + require.NoError(t, os.Symlink(linkTarget, dir)) + // A trailing slash would make Lstat follow the link + assert.Error(t, ensureExtensionsDir(dir+"/")) + assert.Empty(t, *owned) + info, err := os.Stat(linkTarget) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0777), info.Mode().Perm(), "the link target is left alone") + }) + t.Run("regular file is refused", func(t *testing.T) { + fakeSetOwner(t) + dir := filepath.Join(t.TempDir(), "extensions") + require.NoError(t, os.WriteFile(dir, nil, 0644)) + assert.Error(t, ensureExtensionsDir(dir)) + }) +} - root := t.TempDir() - dir := filepath.Join(root, "extensions") - prevConfig, prevURLs := appConfig, osctrlURLs - t.Cleanup(func() { appConfig, osctrlURLs = prevConfig, prevURLs }) - appConfig.OsctrlSecret = "test-secret" - appConfig.ExtensionsDir = dir - osctrlURLs.Extensions = server.URL + "/manifest" +func TestRemoveManagedFileRefusesSymlink(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "victim") + require.NoError(t, os.WriteFile(outside, []byte("keep"), 0644)) + link := filepath.Join(dir, "evil.ext") + require.NoError(t, os.Symlink(outside, link)) + + assert.Error(t, removeManagedFile(dir, "evil.ext")) + _, err := os.Lstat(link) + assert.NoError(t, err, "the symlink itself is not removed either") + assert.FileExists(t, outside) + + // Nothing outside dir, nor dir itself, can be named + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(dir), "sibling.ext"), nil, 0644)) + t.Cleanup(func() { _ = os.Remove(filepath.Join(filepath.Dir(dir), "sibling.ext")) }) + for _, name := range []string{"../sibling.ext", "", ".", "..", "sub/x.ext"} { + assert.Error(t, removeManagedFile(dir, name), name) + } + assert.FileExists(t, filepath.Join(filepath.Dir(dir), "sibling.ext")) - changed, err := syncExtensions() + // A regular file is removed, and an absent one is already done + require.NoError(t, os.WriteFile(filepath.Join(dir, "good.ext"), nil, 0755)) + require.NoError(t, removeManagedFile(dir, "good.ext")) + assert.NoFileExists(t, filepath.Join(dir, "good.ext")) + assert.NoError(t, removeManagedFile(dir, "good.ext")) +} + +func TestRetrieveExtensionManifestRequest(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + got = nil + assert.NoError(t, json.NewDecoder(r.Body).Decode(&got)) + _ = json.NewEncoder(w).Encode([]ExtensionEntry{testEntry(testExtBody)}) + })) + defer server.Close() + + entries, err := retrieveExtensionManifest(ExtensionsRequest{OsctrlSecret: testExtSecret, Platform: "linux", Arch: "amd64"}, server.URL, false) require.NoError(t, err) - assert.True(t, changed, "the valid entry is still installed") - assert.FileExists(t, filepath.Join(dir, "good.ext")) - assert.NoFileExists(t, filepath.Join(root, "escape.ext")) - assert.NoDirExists(t, filepath.Join(dir, "sub")) - entries, err := os.ReadDir(dir) + require.Len(t, entries, 1) + assert.Equal(t, testEntry(testExtBody), entries[0]) + assert.Equal(t, map[string]any{"secret": testExtSecret, "platform": "linux", "arch": "amd64"}, got, + "uuid and status are omitted when empty") + + at := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + _, err = retrieveExtensionManifest(ExtensionsRequest{ + OsctrlSecret: testExtSecret, UUID: "u-1", Platform: "darwin", Arch: "arm64", + Status: []ExtensionStatus{{Name: "osquery_exec", SHA256: "abc", Result: "rejected", Error: "boom", At: at}}, + }, server.URL, false) require.NoError(t, err) - assert.Len(t, entries, 1, "only good.ext may be written") + assert.Equal(t, "u-1", got["uuid"]) + assert.Equal(t, []any{map[string]any{ + "name": "osquery_exec", "sha256": "abc", "result": "rejected", "error": "boom", "at": "2026-10-19T12:00:00Z", + }}, got["status"]) + + notFound := httptest.NewServer(http.NotFoundHandler()) + defer notFound.Close() + entries, err = retrieveExtensionManifest(ExtensionsRequest{OsctrlSecret: testExtSecret}, notFound.URL, false) + assert.NoError(t, err) + assert.Empty(t, entries) + + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer failing.Close() + _, err = retrieveExtensionManifest(ExtensionsRequest{OsctrlSecret: testExtSecret}, failing.URL, false) + assert.Error(t, err) } diff --git a/cmd/osctrld/extguard.go b/cmd/osctrld/extguard.go new file mode 100644 index 0000000..8f0f82e --- /dev/null +++ b/cmd/osctrld/extguard.go @@ -0,0 +1,879 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/rs/zerolog/log" + "github.com/shirou/gopsutil/v3/process" +) + +const ( + // disabledByFlagsError is the informational status when osquery will not load extensions + disabledByFlagsError = "extensions are disabled by flags" + // maxStatusError caps each status error, in bytes + maxStatusError = 256 + // maxStatusPerRequest caps the statuses sent at once, newest kept; at the + // error cap they stay under osctrl-tls's 16 KiB body limit + maxStatusPerRequest = 24 + // applyKept reports a removal that failed: the extension is still installed + applyKept = "kept" + // applyRemoved reports a removal that worked + applyRemoved = "removed" +) + +var ( + // extensionsGOOS picks file names, autoload defaults and path matching; a + // seam so the Windows flow is tested anywhere + extensionsGOOS = runtime.GOOS + // autoloadDirSafe reports whether only an administrator can write in the + // autoload file's directory. A seam for tests. + autoloadDirSafe = adminOwnedDir + // extensionRunning is a seam over the per-extension process check + extensionRunning = isExtensionRunning + // processExes is a seam over the executable paths of running processes + processExes = listProcessExes + // staleNameRe matches the temp names osctrld leaves in the extensions + // directory: interrupted downloads (installExtension) and retired files + staleNameRe = regexp.MustCompile(`^\.[A-Za-z0-9_-]{1,64}\.(ext|exe)(\.prev)?\.tmp-`) +) + +// extState is what osctrld remembers between syncs about the extensions it manages +type extState struct { + // Installed is name → sha256 of every extension osctrld placed + Installed map[string]string `json:"installed"` + // Lines are the autoload lines osctrld owns + Lines []string `json:"lines"` + // Rejected is name → sha256 of content that failed the check, skipped while served + Rejected map[string]string `json:"rejected"` + // Pending is name → sha256 changed by the last step and not verified yet; + // an empty sha256 is a removal + Pending map[string]string `json:"pending,omitempty"` + // Status is reported to osctrl on the next request + Status []ExtensionStatus `json:"status,omitempty"` + // Previous is name → sha256 of the version kept as .prev until verified + Previous map[string]string `json:"previous,omitempty"` + // RejectedRemovals is name → sha256 of an extension whose removal kept + // osqueryd down; it stays installed until osctrl serves that name again + RejectedRemovals map[string]string `json:"rejected_removals,omitempty"` + // Restarted is set once osquery was restarted for the pending changes, so + // a resumed check knows whether osquery loaded them + Restarted bool `json:"restarted,omitempty"` + // Autoload is the autoload file osctrld last wrote its lines to + Autoload string `json:"autoload,omitempty"` + // RefusedAutoload is the last autoload path refused, reported once + RefusedAutoload string `json:"refused_autoload,omitempty"` +} + +func extStatePath() string { + return appConfig.OsqueryFlagFile + ".osctrld-extensions-state.json" +} + +// loadExtState reads the state, falling back to an empty one on any problem +func loadExtState(path string) extState { + var s extState + b, err := os.ReadFile(path) + if err == nil { + err = json.Unmarshal(b, &s) + } + if err != nil { + log.Debug().Err(err).Str("path", path).Msg("no usable extensions state") + s = extState{} + } + if s.Installed == nil { + s.Installed = map[string]string{} + } + if s.Rejected == nil { + s.Rejected = map[string]string{} + } + if s.RejectedRemovals == nil { + s.RejectedRemovals = map[string]string{} + } + return s +} + +func saveExtState(path string, s extState) error { + return writeStateFile(path, s) +} + +// flagValue returns the last value of --name in the flagfile at path, as +// gflags reads it: "--name=value", or a bare "--name" with an empty value +func flagValue(path, name string) (value string, found bool) { + b, err := os.ReadFile(path) + if err != nil { + return "", false + } + for _, line := range strings.Split(string(b), "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "-") { + continue + } + if k, v, _ := strings.Cut(strings.TrimLeft(line, "-"), "="); k == name { + value, found = v, true + } + } + return value, found +} + +// autoloadPath is the autoload file osquery reads: --extensions_autoload from +// the flagfile, else osquery's default for goos +func autoloadPath(flagfile string, goos string) string { + if v, _ := flagValue(flagfile, "extensions_autoload"); v != "" { + return filepath.Clean(v) + } + switch goos { + case WindowsOS: + return `C:\Program Files\osquery\extensions.load` + case DarwinOS: + return "/private/var/osquery/extensions.load" + } + return "/etc/osquery/extensions.load" +} + +// disabledByFlags reports whether the flagfile turns extensions off +func disabledByFlags(flagfile string) bool { + v, found := flagValue(flagfile, "disable_extensions") + if !found { + return false + } + switch strings.ToLower(v) { + case "", "true", "1", "yes", "t", "y": + return true + } + return false +} + +// writeAutoload replaces osctrld's lines (prevOwned) in the autoload file with +// owned, sorted after the lines it does not own, which keep their order. The +// file is written atomically and handed to the administrator; see +// checkAutoloadPath for the paths refused. +func writeAutoload(path string, owned []string, prevOwned []string) error { + _, err := updateAutoload(path, owned, prevOwned) + return err +} + +// protectedFiles are files an autoload path from the flags must never name +func protectedFiles() []string { + return []string{ + appConfig.OsqueryFlagFile, + appConfig.OsquerySecretFile, + appConfig.OsqueryCertFile, + flagsStatePath(), + extStatePath(), + } +} + +// checkAutoloadPath refuses an autoload path osctrld must not write as root. +// The path comes from flags, which environment admins edit, so it must be a +// .load file directly in osquery's base directory (never e.g. /etc/sudoers), +// and not a symlink, one of the files osctrld and osquery depend on, inside +// the extensions directory, or in a directory others can write. +func checkAutoloadPath(path string) error { + if !filepath.IsAbs(path) { + return fmt.Errorf("autoload path %q is not absolute, refusing to write it", path) + } + clean := filepath.Clean(path) + if appConfig.OsqueryPath == "" { + return fmt.Errorf("osquery base directory is not configured, refusing to write autoload path %s", path) + } + base := filepath.Clean(appConfig.OsqueryPath) + inBase := filepath.Dir(clean) == base && strings.HasSuffix(clean, ".load") + if extensionsGOOS == WindowsOS { + inBase = strings.EqualFold(filepath.Dir(clean), base) && strings.HasSuffix(strings.ToLower(clean), ".load") + } + if !inBase { + return fmt.Errorf("autoload path %s is not a .load file directly in %s, refusing to write it", path, base) + } + info, err := os.Lstat(clean) + if err == nil && !info.Mode().IsRegular() { + return fmt.Errorf("autoload file %s is not a regular file, refusing to write it", path) + } + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error checking autoload file - %v", err) + } + for _, p := range protectedFiles() { + if p == "" { + continue + } + same := filepath.Clean(p) == clean + if pi, perr := os.Stat(p); !same && err == nil && perr == nil { + same = os.SameFile(info, pi) + } + if same { + return fmt.Errorf("autoload path %s is %s, refusing to write it", path, p) + } + } + if dir := filepath.Dir(clean); dir == filepath.Clean(appConfig.ExtensionsDir) { + return fmt.Errorf("autoload path %s is inside the extensions directory, refusing to write it", path) + } else if !autoloadDirSafe(dir) { + return fmt.Errorf("autoload directory %s is not owned by root or is writable by others, refusing to write in it", dir) + } + return nil +} + +// updateAutoload is writeAutoload, reporting whether it wrote. A file that +// already holds exactly the owned lines is left as it is. +func updateAutoload(path string, owned []string, prevOwned []string) (changed bool, err error) { + if err := checkAutoloadPath(path); err != nil { + return false, err + } + // The checks were made on the cleaned path, so every access uses it + path = filepath.Clean(path) + var current []byte + mode := os.FileMode(0644) + if info, err := os.Lstat(path); err == nil { + // Keep an operator's mode, minus group/world write + mode = info.Mode().Perm() &^ 0022 + if current, err = os.ReadFile(path); err != nil { + return false, fmt.Errorf("error reading autoload file - %v", err) + } + } + owned = slices.Sorted(slices.Values(owned)) + var lines, ours []string + for _, l := range strings.Split(string(current), "\n") { + l = strings.TrimSpace(l) + switch { + case l == "": + case slices.Contains(owned, l) || slices.Contains(prevOwned, l): + ours = append(ours, l) + default: + lines = append(lines, l) + } + } + slices.Sort(ours) + if slices.Equal(slices.Compact(ours), owned) { + return false, nil + } + lines = append(lines, owned...) + content := "" + if len(lines) > 0 { + content = strings.Join(lines, "\n") + "\n" + } + tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-*") + if err != nil { + return false, fmt.Errorf("error writing autoload file - %v", err) + } + defer func() { + if err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + } + }() + if _, err = tmp.WriteString(content); err != nil { + return false, fmt.Errorf("error writing autoload file - %v", err) + } + if err = tmp.Close(); err != nil { + return false, fmt.Errorf("error writing autoload file - %v", err) + } + if err = setOwner(tmp.Name()); err != nil { + return false, fmt.Errorf("error setting owner of autoload file - %v", err) + } + if err = os.Chmod(tmp.Name(), mode); err != nil { + return false, fmt.Errorf("error setting mode on autoload file - %v", err) + } + if err = os.Rename(tmp.Name(), path); err != nil { + return false, fmt.Errorf("error writing autoload file - %v", err) + } + return true, nil +} + +func listProcessExes() ([]string, error) { + ps, err := process.Processes() + if err != nil { + return nil, err + } + exes := make([]string, 0, len(ps)) + for _, p := range ps { + if exe, err := p.Exe(); err == nil && exe != "" { + exes = append(exes, exe) + } + } + return exes, nil +} + +// isExtensionRunning reports whether a process runs the executable at path +func isExtensionRunning(path string) (bool, error) { + exes, err := processExes() + if err != nil { + return false, fmt.Errorf("error getting processes - %v", err) + } + want := []string{filepath.Clean(path)} + // The kernel reports the resolved path, e.g. /private/var for /var on macOS + if resolved, err := filepath.EvalSymlinks(path); err == nil { + want = append(want, resolved) + } + for _, exe := range exes { + exe = filepath.Clean(exe) + for _, w := range want { + if exe == w || (extensionsGOOS == WindowsOS && strings.EqualFold(exe, w)) { + return true, nil + } + } + } + return false, nil +} + +// cachedHostUUID is the host UUID the flags step looked up (or last knew) this sync +func cachedHostUUID() string { + return loadFlagsState(flagsStatePath()).HostUUID +} + +// moveAside renames a regular file in dir to .prev. A rename works on +// Windows while the executable runs, where overwriting or deleting it fails. +func moveAside(dir, fileName string) (bool, error) { + path := filepath.Join(dir, fileName) + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, err + } + if !info.Mode().IsRegular() { + return false, fmt.Errorf("refusing to move %s, it is not a regular file", path) + } + return true, os.Rename(path, path+".prev") +} + +// retire moves a file osctrld no longer wants to a temp name sweepStale +// deletes, so a file Windows still holds open is deleted on a later sweep +func retire(dir, fileName string) { + path := filepath.Join(dir, fileName) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return + } + to := filepath.Join(dir, fmt.Sprintf(".%s.tmp-retired-%d", fileName, time.Now().UnixNano())) + if err := os.Rename(path, to); err != nil { + log.Error().Err(err).Str("path", path).Msg("failed to remove extension file") + } +} + +// sweepStale deletes osctrld's own temp files left in dir: interrupted +// downloads and retired files. Anything else is left alone. +func sweepStale(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + log.Debug().Err(err).Str("dir", dir).Msg("cannot list extensions directory") + return + } + for _, e := range entries { + if !staleNameRe.MatchString(e.Name()) { + continue + } + if err := removeManagedFile(dir, e.Name()); err != nil { + // Windows keeps a running executable locked; the next sweep retries + log.Debug().Err(err).Str("file", e.Name()).Msg("cannot remove stale extension file yet") + } + } +} + +// ownedLines are the autoload lines for every installed extension +func ownedLines(dir string, installed map[string]string) []string { + lines := make([]string, 0, len(installed)) + for name := range installed { + if f, err := extensionFileName(name, extensionsGOOS); err == nil { + lines = append(lines, filepath.Join(dir, f)) + } + } + slices.Sort(lines) + return lines +} + +// upToDate reports whether path already holds sha256 in a file only an +// administrator can have written +func upToDate(path, sha string) bool { + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return false + } + sum, err := fileSHA256(path) + return err == nil && sum == sha && extensionFileSafe(path) +} + +// removalDownMsg is the kept error of a removal undone because osqueryd did not +// stay up without the extension +func removalDownMsg() string { + return fmt.Sprintf("osqueryd was not running %gs after the extension was removed", restartCheckTimeout.Seconds()) +} + +// latestStatus keeps the newest status per name, in order +func latestStatus(all []ExtensionStatus) []ExtensionStatus { + seen := map[string]bool{} + var out []ExtensionStatus + for i := len(all) - 1; i >= 0; i-- { + if !seen[all[i].Name] { + seen[all[i].Name] = true + out = append(out, all[i]) + } + } + slices.Reverse(out) + return out +} + +func newStatus(name, sha, result, msg string) ExtensionStatus { + if len(msg) > maxStatusError { + n := maxStatusError + for n > 0 && !utf8.RuneStart(msg[n]) { + n-- + } + msg = msg[:n] + } + return ExtensionStatus{Name: name, SHA256: sha, Result: result, Error: msg, At: time.Now().UTC()} +} + +// restorePrevious undoes the pending changes in dir: every changed file is +// retired and its .prev, if any, put back; Installed follows +func restorePrevious(st *extState, dir string) { + for name := range st.Pending { + f, err := extensionFileName(name, extensionsGOOS) + if err != nil { + continue + } + retire(dir, f) + old, had := st.Previous[name] + if !had { + delete(st.Installed, name) + continue + } + path := filepath.Join(dir, f) + if err := os.Rename(path+".prev", path); err != nil { + log.Error().Err(err).Str("name", name).Msg("failed to restore previous extension") + delete(st.Installed, name) + continue + } + st.Installed[name] = old + } +} + +// syncExtensionsStep reports the last statuses, retrieves the extensions +// assigned to this host and installs, updates and removes osctrld's own +// extensions and autoload lines to match. Replaced and removed files are kept +// as .prev, and the changes marked pending, until +// verifyExtensionsAfterRestart checks them. The autoload file is reconciled +// every time; repairing it re-checks the installed set. It reports whether +// osquery needs a restart. +func syncExtensionsStep(ctx context.Context) (changed bool, err error) { + statePath := extStatePath() + st := loadExtState(statePath) + if len(st.Pending) > 0 { + return false, errors.New("the last extensions change is not verified yet") + } + st.Status = latestStatus(st.Status) + req := ExtensionsRequest{ + OsctrlSecret: appConfig.OsctrlSecret, + UUID: cachedHostUUID(), + Platform: extensionsGOOS, + Arch: runtime.GOARCH, + Status: st.Status[max(0, len(st.Status)-maxStatusPerRequest):], + } + entries, err := retrieveExtensionManifest(req, osctrlURLs.Extensions, appConfig.Insecure) + var statusErr *httpStatusError + if errors.As(err, &statusErr) && (statusErr.code == http.StatusBadRequest || statusErr.code == http.StatusRequestEntityTooLarge) && len(st.Status) > 0 { + // osctrl refuses this request and would refuse it again: drop the status. + // Others (e.g. 403 after a secret rotation) keep it, it may be a one-time kept. + log.Warn().Int("code", statusErr.code).Msg("osctrl refused the extensions status, dropping it") + st.Status = nil + if saveErr := saveExtState(statePath, st); saveErr != nil { + log.Error().Err(saveErr).Msg("failed to save extensions state") + } + } + if err != nil { + // Otherwise status stays for the next request + return false, err + } + if len(st.Status) > 0 { + st.Status = nil + if err := saveExtState(statePath, st); err != nil { + return false, err + } + } + served := map[string]string{} + for _, e := range entries { + served[e.Name] = e.SHA256 + } + dirty := false + for name, sha := range st.Rejected { + // Retried once osctrl serves different content, or assigns it again later + if served[name] != sha { + delete(st.Rejected, name) + dirty = true + } + } + for name, sha := range st.RejectedRemovals { + // Retried once osctrl serves the name again, which ends this removal + if served[name] != "" || st.Installed[name] != sha { + delete(st.RejectedRemovals, name) + dirty = true + if served[name] == sha && st.Installed[name] == sha { + // Served again as installed: nothing to install, so nothing + // else would replace the kept status + st.Status = append(st.Status, newStatus(name, sha, applyOK, "")) + } + continue + } + // Reported every sync while it stays, so osctrl keeps the evidence + // even if its record of the first report is gone + st.Status = append(st.Status, newStatus(name, sha, applyKept, removalDownMsg())) + dirty = true + } + // Saves only what changed, so a host without extensions gets no state file + saveIfDirty := func() error { + if !dirty { + return nil + } + return saveExtState(statePath, st) + } + if len(entries) == 0 && len(st.Installed) == 0 && len(st.Lines) == 0 { + return false, saveIfDirty() + } + autoload := autoloadPath(appConfig.OsqueryFlagFile, extensionsGOOS) + if err := checkAutoloadPath(autoload); err != nil { + // Nothing is changed while osquery reads a file osctrld must not write. + // Every served extension is affected; reported once per refused path. + if st.RefusedAutoload != autoload { + for _, e := range entries { + st.Status = append(st.Status, newStatus(e.Name, e.SHA256, applyRejected, err.Error())) + } + st.RefusedAutoload = autoload + dirty = true + } + if saveErr := saveIfDirty(); saveErr != nil { + log.Error().Err(saveErr).Msg("failed to save extensions state") + } + return false, err + } + if st.RefusedAutoload != "" { + st.RefusedAutoload = "" + dirty = true + } + dir := filepath.Clean(appConfig.ExtensionsDir) + if err := ensureExtensionsDir(dir); err != nil { + return false, err + } + sweepStale(dir) + + pending, previous := map[string]string{}, map[string]string{} + wanted := map[string]bool{} + for name := range st.RejectedRemovals { + wanted[name] = true + } + for _, e := range entries { + if st.Rejected[e.Name] == e.SHA256 { + // Keep the last good version, if any, while the rejected one is served + _, wanted[e.Name] = st.Installed[e.Name] + log.Warn().Str("name", e.Name).Str("sha256", e.SHA256).Msg("extension was rejected before, skipping it") + continue + } + f, err := extensionFileName(e.Name, extensionsGOOS) + if err != nil { + log.Error().Err(err).Msg("skipping extension") + st.Status = append(st.Status, newStatus(e.Name, e.SHA256, applyRejected, err.Error())) + dirty = true + continue + } + wanted[e.Name] = true + path := filepath.Join(dir, f) + if upToDate(path, e.SHA256) { + if st.Installed[e.Name] != e.SHA256 { + // Already in place but not tracked (lost state): adopt and verify it + st.Installed[e.Name] = e.SHA256 + pending[e.Name] = e.SHA256 + } + continue + } + old, _ := fileSHA256(path) + moved, err := moveAside(dir, f) + if err != nil { + log.Error().Err(err).Str("name", e.Name).Msg("failed to replace extension") + st.Status = append(st.Status, newStatus(e.Name, e.SHA256, applyRejected, err.Error())) + dirty = true + continue + } + if _, _, err := installExtension(dir, e, extensionsGOOS, appConfig.OsctrlSecret, appConfig.Insecure); err != nil { + log.Error().Err(err).Str("name", e.Name).Msg("failed to install extension") + // Not remembered as rejected: a download can fail for many reasons + st.Status = append(st.Status, newStatus(e.Name, e.SHA256, applyRejected, err.Error())) + dirty = true + if moved { + if err := os.Rename(path+".prev", path); err != nil { + log.Error().Err(err).Str("name", e.Name).Msg("failed to put the previous extension back") + } + } + continue + } + log.Info().Str("name", e.Name).Str("sha256", e.SHA256).Msg("extension installed") + if moved { + previous[e.Name] = old + } + st.Installed[e.Name] = e.SHA256 + pending[e.Name] = e.SHA256 + } + for name, sha := range st.Installed { + if wanted[name] { + continue + } + f, err := extensionFileName(name, extensionsGOOS) + if err != nil { + delete(st.Installed, name) + dirty = true + continue + } + moved, err := moveAside(dir, f) + if err != nil { + // Still installed and autoloaded; retried on the next sync + log.Error().Err(err).Str("name", name).Msg("failed to remove extension") + st.Status = append(st.Status, newStatus(name, sha, applyKept, err.Error())) + dirty = true + continue + } + if moved { + previous[name] = sha + } else { + // Already gone from disk: nothing a restart could undo + st.Status = append(st.Status, newStatus(name, sha, applyRemoved, "")) + } + log.Info().Str("name", name).Msg("extension removed") + delete(st.Installed, name) + pending[name] = "" + } + + st.Pending, st.Previous, st.Restarted = pending, previous, false + lines := ownedLines(dir, st.Installed) + repaired, err := updateAutoload(autoload, lines, st.Lines) + if err != nil { + // osquery would not load the new set, so the check would blame the + // extensions: undo the files instead and report why + restorePrevious(&st, dir) + for name, sha := range pending { + if sha != "" { + st.Status = append(st.Status, newStatus(name, sha, applyRejected, err.Error())) + } else if old, ok := st.Installed[name]; ok { + st.Status = append(st.Status, newStatus(name, old, applyKept, err.Error())) + } + } + st.Pending, st.Previous = nil, nil + sweepStale(dir) + if saveErr := saveExtState(statePath, st); saveErr != nil { + log.Error().Err(saveErr).Msg("failed to save extensions state") + } + return false, err + } + if st.Autoload != "" && st.Autoload != autoload { + // The flags moved the autoload file: take osctrld's lines out of the old one + if _, err := updateAutoload(st.Autoload, nil, st.Lines); err != nil { + log.Warn().Err(err).Str("path", st.Autoload).Msg("cannot clean the previous autoload file") + } + } + if st.Autoload != autoload || !slices.Equal(st.Lines, lines) { + st.Autoload, st.Lines = autoload, lines + dirty = true + } + if len(pending) == 0 && repaired { + // osquery was not loading osctrld's lines from this file: check the + // installed set as if it were new + log.Warn().Str("path", autoload).Msg("autoload file was missing osctrld's lines, rewrote it") + for name, sha := range st.Installed { + pending[name] = sha + } + } + if len(pending) == 0 { + st.Pending, st.Previous = nil, nil + return false, saveIfDirty() + } + if err := saveExtState(statePath, st); err != nil { + return false, err + } + return true, nil +} + +// waitForExtensions polls until each path has been seen running on +// runningChecks consecutive polls, or restartCheckTimeout passes, and returns +// the names that were not +func waitForExtensions(ctx context.Context, paths map[string]string) ([]string, error) { + seen := map[string]int{} + if len(paths) > 0 { + _, err := pollUntil(ctx, func() bool { + done := true + for name, path := range paths { + if seen[name] >= runningChecks { + continue + } + running, err := extensionRunning(path) + if err != nil { + log.Debug().Err(err).Str("name", name).Msg("error checking extension") + } + if running { + seen[name]++ + } else { + seen[name] = 0 + } + done = done && seen[name] >= runningChecks + } + return done + }) + if err != nil { + return nil, err + } + } + var failed []string + for name := range paths { + if seen[name] < runningChecks { + failed = append(failed, name) + } + } + slices.Sort(failed) + return failed, nil +} + +// verifyExtensionsAfterRestart checks the pending extensions after osquery +// restarted. osqueryd not running: the previous set is restored (a restored +// removal is reported kept) and osquery restarted. An extension whose process +// is not running: its new file is retired, an update's previous version put +// back, the content remembered as rejected, and osquery restarted once. A +// removal that went through is reported removed. The outcome is reported on +// the next extensions request. The rollback restart is not verified. +func verifyExtensionsAfterRestart(ctx context.Context) error { + statePath := extStatePath() + st := loadExtState(statePath) + if len(st.Pending) == 0 { + return nil + } + running, err := waitForOsqueryd(ctx) + if err != nil { + // Pending stays set, the next sync resumes the check + return err + } + dir := filepath.Clean(appConfig.ExtensionsDir) + autoload := autoloadPath(appConfig.OsqueryFlagFile, extensionsGOOS) + restart := false + if !running { + log.Error().Msg("osqueryd is not running after extensions changed, restoring the previous extensions") + msg := fmt.Sprintf("osqueryd was not running %gs after extensions changed", restartCheckTimeout.Seconds()) + restorePrevious(&st, dir) + for name, sha := range st.Pending { + if sha != "" { + st.Rejected[name] = sha + st.Status = append(st.Status, newStatus(name, sha, applyRejected, msg)) + } else if old, ok := st.Installed[name]; ok { + // Restored; kept until osctrl serves the name again, e.g. while + // the flags still --extensions_require it + st.RejectedRemovals[name] = old + st.Status = append(st.Status, newStatus(name, old, applyKept, removalDownMsg())) + } + } + restart = true + } else { + paths := map[string]string{} + for name, sha := range st.Pending { + if f, err := extensionFileName(name, extensionsGOOS); err == nil && sha != "" { + paths[name] = filepath.Join(dir, f) + } + } + disabled := disabledByFlags(appConfig.OsqueryFlagFile) + var failed []string + if !disabled { + if failed, err = waitForExtensions(ctx, paths); err != nil { + return err + } + } + msg := fmt.Sprintf("extension process was not running %gs after restart", restartCheckTimeout.Seconds()) + restored := map[string]bool{} + for name := range paths { + sha := st.Pending[name] + switch { + case disabled: + st.Status = append(st.Status, newStatus(name, sha, applyOK, disabledByFlagsError)) + case slices.Contains(failed, name): + log.Error().Str("name", name).Str("sha256", sha).Msg("extension is not running, removing it") + retire(dir, filepath.Base(paths[name])) + delete(st.Installed, name) + if old, ok := st.Previous[name]; ok { + // An update: the last good version goes back in its place + if err := os.Rename(paths[name]+".prev", paths[name]); err != nil { + log.Error().Err(err).Str("name", name).Msg("failed to restore previous extension") + } else { + st.Installed[name] = old + restored[name] = true + } + } + st.Rejected[name] = sha + st.Status = append(st.Status, newStatus(name, sha, applyRejected, msg)) + restart = true + default: + log.Info().Str("name", name).Str("sha256", sha).Msg("extension is running") + st.Status = append(st.Status, newStatus(name, sha, applyOK, "")) + } + } + for name, old := range st.Previous { + if f, err := extensionFileName(name, extensionsGOOS); err == nil && !restored[name] { + retire(dir, f+".prev") + } + if st.Pending[name] == "" { + st.Status = append(st.Status, newStatus(name, old, applyRemoved, "")) + } + } + } + st.Pending, st.Previous, st.Restarted = nil, nil, false + if restart { + lines := ownedLines(dir, st.Installed) + if err := writeAutoload(autoload, lines, st.Lines); err != nil { + log.Error().Err(err).Msg("failed to write autoload file") + } else { + st.Lines, st.Autoload = lines, autoload + } + } + if err := saveExtState(statePath, st); err != nil { + return err + } + if restart { + if err := restartOsquery(); err != nil { + log.Error().Err(err).Msg("failed to restart osquery") + } + } + sweepStale(dir) + return nil +} + +// restartForExtensions restarts osquery for the pending extensions and records +// that it did, even when the restart failed: the check then tells +func restartForExtensions() { + if err := restartOsquery(); err != nil { + log.Error().Err(err).Msg("failed to restart osquery") + } + path := extStatePath() + st := loadExtState(path) + if len(st.Pending) == 0 { + return + } + st.Restarted = true + if err := saveExtState(path, st); err != nil { + log.Error().Err(err).Msg("failed to save extensions state") + } +} + +// resumeExtensionsCheck finishes a check an earlier sync did not complete. +// osquery is restarted first unless that already happened for these changes. +func resumeExtensionsCheck(ctx context.Context) error { + st := loadExtState(extStatePath()) + if len(st.Pending) == 0 { + return nil + } + if !st.Restarted { + log.Info().Msg("restarting osquery for unverified extensions") + restartForExtensions() + } + log.Info().Msg("resuming check of unverified extensions") + return verifyExtensionsAfterRestart(ctx) +} diff --git a/cmd/osctrld/extguard_test.go b/cmd/osctrld/extguard_test.go new file mode 100644 index 0000000..ee30376 --- /dev/null +++ b/cmd/osctrld/extguard_test.go @@ -0,0 +1,1317 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// extServer is a fake osctrl serving an extensions list and downloads for env "dev" +type extServer struct { + mu sync.Mutex + list []ExtensionEntry + files map[string][]byte // sha256 → body + listCode int + requests []ExtensionsRequest +} + +func (s *extServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + if r.URL.Path == "/dev/osctrld-extensions" { + var req ExtensionsRequest + _ = json.NewDecoder(r.Body).Decode(&req) + s.requests = append(s.requests, req) + if s.listCode != 0 { + w.WriteHeader(s.listCode) + return + } + _ = json.NewEncoder(w).Encode(s.list) + return + } + if r.URL.Path == "/dev/osctrld-extension" { + var req struct { + Secret string `json:"secret"` + SHA256 string `json:"sha256"` + } + if json.NewDecoder(r.Body).Decode(&req) == nil && req.Secret != "" && s.files[req.SHA256] != nil { + _, _ = w.Write(s.files[req.SHA256]) + return + } + } + http.NotFound(w, r) +} + +// serve replaces the served list; each pair is a name and the file body +func (s *extServer) serve(pairs ...string) { + s.mu.Lock() + defer s.mu.Unlock() + s.list = nil + for i := 0; i+1 < len(pairs); i += 2 { + body := []byte(pairs[i+1]) + sha := sha256Hex(pairs[i+1]) + s.files[sha] = body + s.list = append(s.list, ExtensionEntry{Name: pairs[i], SHA256: sha, Size: int64(len(body)), URL: "https://elsewhere.example/" + sha}) + } +} + +func (s *extServer) recorded() []ExtensionsRequest { + s.mu.Lock() + defer s.mu.Unlock() + return append([]ExtensionsRequest(nil), s.requests...) +} + +// extEnv is one test's wiring: fake osctrl, temp dirs and fake processes +type extEnv struct { + srv *extServer + flags *flagsServer + dir string + autoload string + restarts *[][]string + running map[string]bool // extension path → running + mu sync.Mutex +} + +func (e *extEnv) setRunning(path string, running bool) { + e.mu.Lock() + defer e.mu.Unlock() + e.running[path] = running +} + +// extPath is where the named extension lands for the configured goos +func (e *extEnv) extPath(t *testing.T, name string) string { + t.Helper() + f, err := extensionFileName(name, extensionsGOOS) + require.NoError(t, err) + return filepath.Join(e.dir, f) +} + +// setupExt builds on the flags guard wiring and adds an extensions server, an +// extensions directory and an autoload file named in the flagfile +func setupExt(t *testing.T) *extEnv { + t.Helper() + fs, recorded := setupGuard(t, testOverrideFlags, nil) + fakeSetOwner(t) + fakeFileSafe(t, true) + fakeDirSafe(t, true) + + srv := &extServer{files: map[string][]byte{}} + server := httptest.NewServer(srv) + t.Cleanup(server.Close) + notFound := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(notFound.Close) + flagsURL := osctrlURLs.Flags + osctrlURLs = genURLs(server.URL, "dev", false) + osctrlURLs.Flags = flagsURL + osctrlURLs.Cert = notFound.URL + + prevGOOS, prevExtRunning := extensionsGOOS, extensionRunning + t.Cleanup(func() { extensionsGOOS, extensionRunning = prevGOOS, prevExtRunning }) + extensionsGOOS = "linux" + + root := filepath.Dir(appConfig.OsqueryFlagFile) + e := &extEnv{ + srv: srv, + flags: fs, + dir: filepath.Join(root, "extensions"), + autoload: filepath.Join(root, "extensions.load"), + restarts: recorded, + running: map[string]bool{}, + } + appConfig.ExtensionsDir = e.dir + "/" + appConfig.OsqueryPath = root + "/" + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(e.flagfile("")), 0700)) + extensionRunning = func(path string) (bool, error) { + e.mu.Lock() + defer e.mu.Unlock() + return e.running[path], nil + } + return e +} + +// flagfile is a flagfile pointing osquery at the test autoload file +func (e *extEnv) flagfile(extra string) string { + s := testOverrideFlags + "\n--extensions_autoload=" + e.autoload + if extra != "" { + s += "\n" + extra + } + return s +} + +func (e *extEnv) autoloadLines(t *testing.T) []string { + t.Helper() + b, err := os.ReadFile(e.autoload) + if os.IsNotExist(err) { + return nil + } + require.NoError(t, err) + return strings.Fields(string(b)) +} + +// stepAndVerify runs one extensions step and, if it changed anything, the check +func (e *extEnv) stepAndVerify(t *testing.T) bool { + t.Helper() + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + if changed { + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + } + return changed +} + +func readExtState(t *testing.T) extState { + t.Helper() + return loadExtState(extStatePath()) +} + +func statusFor(st extState, name string) *ExtensionStatus { + for i := range st.Status { + if st.Status[i].Name == name { + return &st.Status[i] + } + } + return nil +} + +func extProcessDownError() string { + return fmt.Sprintf("extension process was not running %gs after restart", restartCheckTimeout.Seconds()) +} + +func removalDownExtError() string { + return fmt.Sprintf("osqueryd was not running %gs after the extension was removed", restartCheckTimeout.Seconds()) +} + +func osquerydDownExtError() string { + return fmt.Sprintf("osqueryd was not running %gs after extensions changed", restartCheckTimeout.Seconds()) +} + +func TestAutoloadPath(t *testing.T) { + dir := t.TempDir() + withFlag := filepath.Join(dir, "with.flags") + require.NoError(t, os.WriteFile(withFlag, []byte("--tls_hostname=x\n--extensions_autoload=/x/y.load\n"), 0600)) + without := filepath.Join(dir, "without.flags") + require.NoError(t, os.WriteFile(without, []byte("--tls_hostname=x\n"), 0600)) + empty := filepath.Join(dir, "empty.flags") + require.NoError(t, os.WriteFile(empty, []byte("--extensions_autoload=\n"), 0600)) + missing := filepath.Join(dir, "missing.flags") + + tests := []struct { + name, flagfile, goos, want string + }{ + {"flag linux", withFlag, "linux", "/x/y.load"}, + {"flag windows", withFlag, "windows", "/x/y.load"}, + {"default linux", without, "linux", "/etc/osquery/extensions.load"}, + {"default darwin", without, "darwin", "/private/var/osquery/extensions.load"}, + {"default windows", without, "windows", `C:\Program Files\osquery\extensions.load`}, + {"empty value", empty, "linux", "/etc/osquery/extensions.load"}, + {"missing flagfile", missing, "darwin", "/private/var/osquery/extensions.load"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, autoloadPath(tt.flagfile, tt.goos)) + }) + } +} + +// fakeDirSafe decides whether the autoload directory counts as admin-only, +// since tests do not run as root +func fakeDirSafe(t *testing.T, safe bool) { + t.Helper() + prev := autoloadDirSafe + autoloadDirSafe = func(string) bool { return safe } + t.Cleanup(func() { autoloadDirSafe = prev }) +} + +// fakeOsqueryPath makes dir osquery's base directory, where autoload files live +func fakeOsqueryPath(t *testing.T, dir string) { + t.Helper() + prev := appConfig.OsqueryPath + appConfig.OsqueryPath = dir + t.Cleanup(func() { appConfig.OsqueryPath = prev }) +} + +func TestWriteAutoloadKeepsOperatorLines(t *testing.T) { + fakeSetOwner(t) + fakeDirSafe(t, true) + dir := t.TempDir() + fakeOsqueryPath(t, dir) + path := filepath.Join(dir, "extensions.load") + require.NoError(t, os.WriteFile(path, []byte("/opt/b.ext\n/opt/a.ext\n/e/c.ext\n"), 0644)) + + require.NoError(t, writeAutoload(path, []string{"/e/z.ext", "/e/d.ext"}, []string{"/e/c.ext"})) + b, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "/opt/b.ext\n/opt/a.ext\n/e/d.ext\n/e/z.ext\n", string(b)) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) + + // A missing file is created; nothing to write creates nothing + created := filepath.Join(dir, "new.load") + require.NoError(t, writeAutoload(filepath.Join(dir, "none.load"), nil, nil)) + assert.NoFileExists(t, filepath.Join(dir, "none.load")) + require.NoError(t, writeAutoload(created, []string{"/e/d.ext"}, nil)) + b, err = os.ReadFile(created) + require.NoError(t, err) + assert.Equal(t, "/e/d.ext\n", string(b)) + + // A symlinked autoload file is refused and its target left alone + target := filepath.Join(dir, "target") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + link := filepath.Join(dir, "link.load") + require.NoError(t, os.Symlink(target, link)) + assert.Error(t, writeAutoload(link, []string{"/e/d.ext"}, nil)) + b, err = os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(b)) + + assert.Error(t, writeAutoload("relative.load", []string{"/e/d.ext"}, nil)) +} + +func TestSyncExtensionsInstallsAndRemoves(t *testing.T) { + e := setupExt(t) + require.NoError(t, os.MkdirAll(e.dir, 0755)) + operatorFile := filepath.Join(e.dir, "z.ext") + require.NoError(t, os.WriteFile(operatorFile, []byte("operator"), 0755)) + require.NoError(t, os.WriteFile(e.autoload, []byte("/opt/operator.ext\n"), 0644)) + x, y := e.extPath(t, "x"), e.extPath(t, "y") + e.setRunning(x, true) + e.setRunning(y, true) + + e.srv.serve("x", "x-v1", "../escape", "bad") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + assert.True(t, changed) + assert.FileExists(t, x) + assert.NoFileExists(t, filepath.Join(filepath.Dir(e.dir), "escape.ext")) + assert.Equal(t, []string{"/opt/operator.ext", x}, e.autoloadLines(t)) + st := readExtState(t) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, st.Installed) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, st.Pending) + assert.Equal(t, []string{x}, st.Lines) + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + + e.srv.serve("y", "y-v1") + assert.True(t, e.stepAndVerify(t)) + assert.NoFileExists(t, x) + assert.NoFileExists(t, x+".prev") + assert.FileExists(t, y) + assert.Equal(t, []string{"/opt/operator.ext", y}, e.autoloadLines(t)) + assert.Equal(t, map[string]string{"y": sha256Hex("y-v1")}, readExtState(t).Installed) + + // Nothing changed: no restart, nothing pending + assert.False(t, e.stepAndVerify(t)) + + e.srv.serve() + assert.True(t, e.stepAndVerify(t)) + assert.NoFileExists(t, y) + assert.Equal(t, []string{"/opt/operator.ext"}, e.autoloadLines(t)) + assert.FileExists(t, operatorFile) + b, err := os.ReadFile(operatorFile) + require.NoError(t, err) + assert.Equal(t, "operator", string(b)) + st = readExtState(t) + assert.Empty(t, st.Installed) + assert.Empty(t, st.Lines) + assert.Empty(t, *e.restarts, "the steps alone never restart") + + // An osctrl without extensions (404) also removes what osctrld installed + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + assert.FileExists(t, x) + e.srv.listCode = http.StatusNotFound + assert.True(t, e.stepAndVerify(t)) + assert.NoFileExists(t, x) + assert.FileExists(t, operatorFile) + assert.Equal(t, []string{"/opt/operator.ext"}, e.autoloadLines(t)) +} + +// A host that never had extensions is left alone: no directory, no autoload file +func TestSyncExtensionsNothingAssigned(t *testing.T) { + e := setupExt(t) + e.srv.listCode = http.StatusNotFound + assert.False(t, e.stepAndVerify(t)) + assert.NoDirExists(t, e.dir) + assert.NoFileExists(t, e.autoload) + assert.NoFileExists(t, extStatePath()) +} + +func TestSyncExtensionsSweepsStaleTemp(t *testing.T) { + e := setupExt(t) + require.NoError(t, os.MkdirAll(e.dir, 0755)) + stale := filepath.Join(e.dir, ".x.ext.tmp-123456") + require.NoError(t, os.WriteFile(stale, []byte("partial"), 0600)) + operatorTmp := filepath.Join(e.dir, ".notes.tmp-1") + require.NoError(t, os.WriteFile(operatorTmp, []byte("mine"), 0600)) + e.setRunning(e.extPath(t, "x"), true) + + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + assert.NoFileExists(t, stale) + assert.FileExists(t, operatorTmp, "only osctrld's own temp names are swept") + assert.Equal(t, []string{e.extPath(t, "x")}, e.autoloadLines(t)) +} + +func TestVerifyExtensionsOK(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + e.setRunning(x, true) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + + e.srv.serve("x", "x-v2") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + b, err := os.ReadFile(x + ".prev") + require.NoError(t, err) + assert.Equal(t, "x-v1", string(b), "the previous version is kept until verified") + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, readExtState(t).Previous) + + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + assert.NoFileExists(t, x+".prev") + b, err = os.ReadFile(x) + require.NoError(t, err) + assert.Equal(t, "x-v2", string(b)) + st := readExtState(t) + assert.Empty(t, st.Pending) + assert.Empty(t, st.Previous) + assert.Empty(t, st.Rejected) + s := statusFor(st, "x") + require.NotNil(t, s) + assert.Equal(t, applyOK, s.Result) + assert.Equal(t, sha256Hex("x-v2"), s.SHA256) + assert.Empty(t, s.Error) + assert.Empty(t, *e.restarts) + assert.Empty(t, tempLeftovers(t, e.dir)) +} + +func TestVerifyExtensionsRemovesFailedOne(t *testing.T) { + e := setupExt(t) + x, y := e.extPath(t, "x"), e.extPath(t, "y") + e.setRunning(x, true) + e.srv.serve("x", "x-v1", "y", "y-v1") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + + assert.FileExists(t, x) + assert.NoFileExists(t, y) + assert.Equal(t, []string{x}, e.autoloadLines(t)) + assert.Len(t, *e.restarts, 1, "one restart without the failed extension") + st := readExtState(t) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, st.Installed) + assert.Equal(t, map[string]string{"y": sha256Hex("y-v1")}, st.Rejected) + assert.Equal(t, []string{x}, st.Lines) + assert.Empty(t, st.Pending) + sx, sy := statusFor(st, "x"), statusFor(st, "y") + require.NotNil(t, sx) + require.NotNil(t, sy) + assert.Equal(t, applyOK, sx.Result) + assert.Equal(t, applyRejected, sy.Result) + assert.Equal(t, sha256Hex("y-v1"), sy.SHA256) + assert.Equal(t, extProcessDownError(), sy.Error) + assert.Empty(t, tempLeftovers(t, e.dir)) +} + +func TestVerifyExtensionsOsquerydDown(t *testing.T) { + e := setupExt(t) + x, y, w := e.extPath(t, "x"), e.extPath(t, "y"), e.extPath(t, "w") + e.setRunning(x, true) + e.setRunning(w, true) + e.srv.serve("x", "x-v1", "w", "w-v1") + assert.True(t, e.stepAndVerify(t)) + + // Update x, add y, remove w; osqueryd then never comes back + e.srv.serve("x", "x-v2", "y", "y-v1") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + assert.NoFileExists(t, w) + osquerydRunning = neverRunning + + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + + b, err := os.ReadFile(x) + require.NoError(t, err) + assert.Equal(t, "x-v1", string(b), "previous version restored") + b, err = os.ReadFile(w) + require.NoError(t, err) + assert.Equal(t, "w-v1", string(b), "removed extension restored") + assert.NoFileExists(t, y) + assert.NoFileExists(t, x+".prev") + assert.Equal(t, []string{w, x}, e.autoloadLines(t)) + assert.Len(t, *e.restarts, 1, "one rollback restart") + + st := readExtState(t) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1"), "w": sha256Hex("w-v1")}, st.Installed) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v2"), "y": sha256Hex("y-v1")}, st.Rejected) + assert.Equal(t, map[string]string{"w": sha256Hex("w-v1")}, st.RejectedRemovals) + assert.Empty(t, st.Pending) + assert.Empty(t, st.Previous) + for _, name := range []string{"x", "y"} { + s := statusFor(st, name) + require.NotNil(t, s, name) + assert.Equal(t, applyRejected, s.Result) + assert.Equal(t, osquerydDownExtError(), s.Error) + } + assert.Equal(t, sha256Hex("x-v2"), statusFor(st, "x").SHA256) + sw := statusFor(st, "w") + require.NotNil(t, sw) + assert.Equal(t, applyKept, sw.Result, "a removal that failed is reported as kept") + assert.Equal(t, sha256Hex("w-v1"), sw.SHA256) + assert.Equal(t, removalDownExtError(), sw.Error) + assert.Empty(t, tempLeftovers(t, e.dir)) + + // The same list again keeps the last good set: x stays at v1, w stays, + // nothing changes and osquery is not restarted + osquerydRunning = func() (bool, error) { return true, nil } + *e.restarts = nil + assert.False(t, e.stepAndVerify(t)) + b, err = os.ReadFile(x) + require.NoError(t, err) + assert.Equal(t, "x-v1", string(b)) + assert.FileExists(t, w) + assert.Equal(t, []string{w, x}, e.autoloadLines(t)) + assert.Empty(t, *e.restarts) +} + +// A removal that keeps osqueryd down (e.g. flags with --extensions_require) is +// rolled back, reported once and not retried while the list stays the same +func TestRejectedRemovalKept(t *testing.T) { + e := setupExt(t) + w := e.extPath(t, "w") + e.setRunning(w, true) + e.srv.serve("w", "w-v1") + assert.True(t, e.stepAndVerify(t)) + + e.srv.serve() + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + osquerydRunning = neverRunning + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + assert.FileExists(t, w) + assert.Equal(t, []string{w}, e.autoloadLines(t)) + assert.Len(t, *e.restarts, 1, "rollback restart") + + osquerydRunning = func() (bool, error) { return true, nil } + *e.restarts = nil + assert.False(t, e.stepAndVerify(t), "same list: removal not retried") + assert.False(t, e.stepAndVerify(t)) + assert.FileExists(t, w) + assert.Equal(t, []string{w}, e.autoloadLines(t)) + assert.Empty(t, *e.restarts) + reqs := e.srv.recorded() + require.Len(t, reqs, 4) + // Reported on the next request and on every one after, so osctrl never + // loses the evidence while the extension stays + for i, r := range reqs[2:] { + require.Len(t, r.Status, 1, i) + assert.Equal(t, "w", r.Status[0].Name) + assert.Equal(t, applyKept, r.Status[0].Result) + assert.Equal(t, sha256Hex("w-v1"), r.Status[0].SHA256) + assert.Equal(t, removalDownExtError(), r.Status[0].Error) + } + + // Once osctrl serves w again the memory goes, the kept version is + // reported ok, and a later removal is retried + e.srv.serve("w", "w-v1") + assert.False(t, e.stepAndVerify(t)) + assert.Empty(t, readExtState(t).RejectedRemovals) + sw := statusFor(readExtState(t), "w") + require.NotNil(t, sw, "served again: reported ok") + assert.Equal(t, applyOK, sw.Result) + assert.Equal(t, sha256Hex("w-v1"), sw.SHA256) + assert.Empty(t, sw.Error) + assert.False(t, e.stepAndVerify(t)) + sent := e.srv.recorded()[5].Status + require.Len(t, sent, 1, "the ok is sent, without a kept") + assert.Equal(t, applyOK, sent[0].Result) + assert.Empty(t, readExtState(t).Status, "ok reported once") + e.srv.serve() + assert.True(t, e.stepAndVerify(t)) + assert.NoFileExists(t, w) + sw = statusFor(readExtState(t), "w") + require.NotNil(t, sw) + assert.Equal(t, applyRemoved, sw.Result, "the retried removal worked") +} + +// A successful removal of an extension osctrld installed is reported as +// removed, with the removed version, whether or not the file was still there +func TestRemovalReported(t *testing.T) { + for _, gone := range []bool{false, true} { + t.Run(fmt.Sprintf("file gone %v", gone), func(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + e.setRunning(x, true) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + if gone { + require.NoError(t, os.Remove(x)) + } + + e.srv.serve() + assert.True(t, e.stepAndVerify(t)) + assert.NoFileExists(t, x) + st := readExtState(t) + require.Len(t, st.Status, 1, "the ok was sent with the removal's request") + s := st.Status[0] + assert.Equal(t, "x", s.Name) + assert.Equal(t, applyRemoved, s.Result) + assert.Equal(t, sha256Hex("x-v1"), s.SHA256) + assert.Empty(t, s.Error) + assert.Empty(t, *e.restarts) + }) + } +} + +// A removal whose file cannot be moved aside leaves the extension installed +// and is reported as kept +func TestRemovalMoveAsideFailsKept(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + e.setRunning(x, true) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + // Something that is not a regular file now sits at the extension's path + require.NoError(t, os.Remove(x)) + require.NoError(t, os.Mkdir(x, 0755)) + + e.srv.serve() + assert.False(t, e.stepAndVerify(t)) + assert.DirExists(t, x) + assert.Equal(t, []string{x}, e.autoloadLines(t)) + st := readExtState(t) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, st.Installed) + s := statusFor(st, "x") + require.NotNil(t, s) + assert.Equal(t, applyKept, s.Result) + assert.Equal(t, sha256Hex("x-v1"), s.SHA256) + assert.Contains(t, s.Error, "not a regular file") +} + +func TestSyncSkipsRejectedUntilChanged(t *testing.T) { + e := setupExt(t) + y := e.extPath(t, "y") + e.setRunning(y, true) + require.NoError(t, saveExtState(extStatePath(), extState{Rejected: map[string]string{"y": sha256Hex("y-v1")}})) + + e.srv.serve("y", "y-v1") + assert.False(t, e.stepAndVerify(t)) + assert.NoFileExists(t, y) + assert.Equal(t, map[string]string{"y": sha256Hex("y-v1")}, readExtState(t).Rejected, "kept while served") + + e.srv.serve("y", "y-v2") + assert.True(t, e.stepAndVerify(t)) + assert.FileExists(t, y) + assert.Empty(t, readExtState(t).Rejected) +} + +func TestStatusReportedOnce(t *testing.T) { + e := setupExt(t) + cached := "11111111-2222-3333-4444-555555555555" + require.NoError(t, saveFlagsState(flagsStatePath(), flagsState{HostUUID: cached})) + at := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + sent := ExtensionStatus{Name: "x", SHA256: sha256Hex("x-v1"), Result: applyOK, At: at} + require.NoError(t, saveExtState(extStatePath(), extState{Status: []ExtensionStatus{sent}})) + + // A failed request keeps the status for the next one + e.srv.listCode = http.StatusInternalServerError + _, err := syncExtensionsStep(context.Background()) + assert.Error(t, err) + assert.Len(t, readExtState(t).Status, 1) + + e.srv.listCode = 0 + _, err = syncExtensionsStep(context.Background()) + require.NoError(t, err) + assert.Empty(t, readExtState(t).Status) + + _, err = syncExtensionsStep(context.Background()) + require.NoError(t, err) + + reqs := e.srv.recorded() + require.Len(t, reqs, 3) + for _, r := range reqs[:2] { + require.Len(t, r.Status, 1) + assert.Equal(t, "x", r.Status[0].Name) + assert.True(t, at.Equal(r.Status[0].At)) + } + assert.Empty(t, reqs[2].Status, "sent once") + assert.Equal(t, cached, reqs[2].UUID, "the flags step's cached host uuid") + assert.Equal(t, testExtSecret, reqs[2].OsctrlSecret) + assert.Equal(t, "linux", reqs[2].Platform) + assert.NotEmpty(t, reqs[2].Arch) +} + +func TestResumePendingExtensionsCheck(t *testing.T) { + for name, restarted := range map[string]bool{"not restarted": false, "restarted": true} { + t.Run(name, func(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + ev := &events{} + execCommand = func(string, ...string) *exec.Cmd { ev.add("restart"); return exec.Command("true") } + extensionRunning = func(path string) (bool, error) { ev.add("extension"); return path == x, nil } + e.srv.serve("x", "x-v1") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + assert.False(t, readExtState(t).Restarted, "a new step is not restarted yet") + if restarted { + restartForExtensions() + require.True(t, readExtState(t).Restarted) + ev.take() + } + + // A pending change blocks a new step until it is verified + _, err = syncExtensionsStep(context.Background()) + assert.Error(t, err) + + require.NoError(t, resumeExtensionsCheck(context.Background())) + if restarted { + assert.Equal(t, []string{"extension"}, ev.take(), "osquery already loaded the change") + } else { + assert.Equal(t, []string{"restart", "extension"}, ev.take(), "restart first, then check") + } + st := readExtState(t) + assert.Empty(t, st.Pending) + assert.False(t, st.Restarted) + require.NotNil(t, statusFor(st, "x")) + assert.Equal(t, applyOK, statusFor(st, "x").Result) + + // Nothing pending: nothing to do + require.NoError(t, resumeExtensionsCheck(context.Background())) + assert.Empty(t, ev.take()) + }) + } +} + +func TestVerifyExtensionsStopsOnCancel(t *testing.T) { + e := setupExt(t) + e.srv.serve("x", "x-v1") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + restartCheckTimeout = time.Minute + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + assert.ErrorIs(t, verifyExtensionsAfterRestart(ctx), context.Canceled) + assert.NotEmpty(t, readExtState(t).Pending, "resumed on the next sync") +} + +func TestDisabledByFlags(t *testing.T) { + e := setupExt(t) + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(e.flagfile("--disable_extensions=true")), 0700)) + checks := 0 + extensionRunning = func(string) (bool, error) { checks++; return false, nil } + + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + assert.FileExists(t, e.extPath(t, "x")) + assert.Zero(t, checks, "no process check") + assert.Empty(t, *e.restarts) + st := readExtState(t) + s := statusFor(st, "x") + require.NotNil(t, s) + assert.Equal(t, applyOK, s.Result) + assert.Equal(t, "extensions are disabled by flags", s.Error) + assert.Empty(t, st.Rejected) +} + +func TestDisabledByFlagsParsing(t *testing.T) { + dir := t.TempDir() + tests := []struct { + content string + want bool + }{ + {"--disable_extensions=true", true}, + {"--disable_extensions", true}, + {"--disable_extensions=1", true}, + {"--disable_extensions=false", false}, + {"--disable_extensions=true\n--disable_extensions=false", false}, + {"--tls_hostname=x", false}, + {"--disable_extensions_foo=true", false}, + } + for i, tt := range tests { + t.Run(tt.content, func(t *testing.T) { + path := filepath.Join(dir, fmt.Sprintf("%d.flags", i)) + require.NoError(t, os.WriteFile(path, []byte(tt.content+"\n"), 0600)) + assert.Equal(t, tt.want, disabledByFlags(path)) + }) + } + assert.False(t, disabledByFlags(filepath.Join(dir, "missing"))) +} + +// events records restarts and process checks in order, collapsing repeats +type events struct { + mu sync.Mutex + log []string +} + +func (ev *events) add(s string) { + ev.mu.Lock() + defer ev.mu.Unlock() + if n := len(ev.log); n > 0 && ev.log[n-1] == s { + return + } + ev.log = append(ev.log, s) +} + +func (ev *events) take() []string { + ev.mu.Lock() + defer ev.mu.Unlock() + l := ev.log + ev.log = nil + return l +} + +func TestSyncOnceFlagsThenExtensions(t *testing.T) { + e := setupExt(t) + ev := &events{} + execCommand = func(name string, args ...string) *exec.Cmd { + ev.add("restart") + return exec.Command("true") + } + osquerydRunning = func() (bool, error) { ev.add("osqueryd"); return true, nil } + extensionRunning = func(string) (bool, error) { ev.add("extension"); return true, nil } + // Served flags carry the test autoload path, as a real flagfile would + e.flags.override = e.flagfile("") + e.flags.env = e.flagfile("") + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(testEnvFlags), 0700)) + + e.srv.serve("x", "x-v1") + syncOnce(context.Background(), newTestCLICommand()) + assert.Equal(t, []string{"restart", "osqueryd", "restart", "osqueryd", "extension"}, ev.take(), + "flags restart and check, then extensions restart and check") + assert.Equal(t, applyOK, loadFlagsState(flagsStatePath()).Last.Result) + assert.Equal(t, applyOK, statusFor(readExtState(t), "x").Result) + + // Only the extensions change: one restart + e.srv.serve("x", "x-v2") + syncOnce(context.Background(), newTestCLICommand()) + assert.Equal(t, []string{"restart", "osqueryd", "extension"}, ev.take()) + + // Nothing changes: no restart, no check + syncOnce(context.Background(), newTestCLICommand()) + assert.Empty(t, ev.take()) +} + +func TestSyncOnceResumesExtensionsCheck(t *testing.T) { + e := setupExt(t) + // The flags step keeps the flagfile as is + e.flags.override = e.flagfile("") + x := e.extPath(t, "x") + require.NoError(t, os.MkdirAll(e.dir, 0755)) + require.NoError(t, os.WriteFile(x, []byte("x-v1"), 0755)) + require.NoError(t, os.WriteFile(e.autoload, []byte(x+"\n"), 0644)) + require.NoError(t, saveExtState(extStatePath(), extState{ + Installed: map[string]string{"x": sha256Hex("x-v1")}, + Lines: []string{x}, + Autoload: e.autoload, + Pending: map[string]string{"x": sha256Hex("x-v1")}, + Restarted: true, + })) + e.setRunning(x, true) + e.srv.serve("x", "x-v1") + + syncOnce(context.Background(), newTestCLICommand()) + + assert.Empty(t, *e.restarts, "resumed without a restart") + assert.Empty(t, readExtState(t).Pending) + // The same sync's extensions request already reports the outcome + reqs := e.srv.recorded() + require.Len(t, reqs, 1) + require.Len(t, reqs[0].Status, 1) + assert.Equal(t, applyOK, reqs[0].Status[0].Result) +} + +// Windows cannot overwrite or delete a running executable but can rename it, +// so osctrld moves a replaced or removed extension aside instead +func TestSyncExtensionsWindowsMovesAside(t *testing.T) { + e := setupExt(t) + extensionsGOOS = WindowsOS + x := e.extPath(t, "x") + assert.True(t, strings.HasSuffix(x, "x.exe")) + e.setRunning(x, true) + + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + assert.Equal(t, []string{x}, e.autoloadLines(t)) + before, err := os.Stat(x) + require.NoError(t, err) + + e.srv.serve("x", "x-v2") + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + prev, err := os.Stat(x + ".prev") + require.NoError(t, err) + assert.True(t, os.SameFile(before, prev), "the running file was renamed, not rewritten") + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + assert.NoFileExists(t, x+".prev") + + before, err = os.Stat(x) + require.NoError(t, err) + e.srv.serve() + changed, err = syncExtensionsStep(context.Background()) + require.NoError(t, err) + require.True(t, changed) + prev, err = os.Stat(x + ".prev") + require.NoError(t, err) + assert.True(t, os.SameFile(before, prev), "a removed extension is renamed aside until osquery restarted") + assert.Empty(t, e.autoloadLines(t)) + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + assert.NoFileExists(t, x+".prev") + assert.Empty(t, tempLeftovers(t, e.dir)) +} + +func TestIsExtensionRunningMatchesExe(t *testing.T) { + prevExes, prevGOOS := processExes, extensionsGOOS + t.Cleanup(func() { processExes, extensionsGOOS = prevExes, prevGOOS }) + + tests := []struct { + name, goos, exe, path string + want bool + }{ + {"same", "linux", "/opt/ext/x.ext", "/opt/ext/x.ext", true}, + {"cleaned", "linux", "/opt/ext/x.ext", "/opt/ext/../ext//x.ext", true}, + {"case differs posix", "linux", "/opt/ext/X.ext", "/opt/ext/x.ext", false}, + {"other file", "linux", "/opt/ext/y.ext", "/opt/ext/x.ext", false}, + {"prefix only", "linux", "/opt/ext/x.ext.prev", "/opt/ext/x.ext", false}, + {"case differs windows", WindowsOS, `C:\Program Files\osquery\Extensions\X.EXE`, `c:\program files\osquery\extensions\x.exe`, true}, + {"other file windows", WindowsOS, `C:\osquery\y.exe`, `C:\osquery\x.exe`, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + extensionsGOOS = tt.goos + processExes = func() ([]string, error) { return []string{"/sbin/launchd", tt.exe}, nil } + got, err := isExtensionRunning(tt.path) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + + processExes = func() ([]string, error) { return nil, errors.New("boom") } + _, err := isExtensionRunning("/opt/ext/x.ext") + assert.Error(t, err) +} + +func TestExtStateCorrupt(t *testing.T) { + dir := t.TempDir() + corrupt := filepath.Join(dir, "corrupt.json") + require.NoError(t, os.WriteFile(corrupt, []byte("{not json"), 0600)) + for _, path := range []string{corrupt, filepath.Join(dir, "missing.json"), dir} { + st := loadExtState(path) + assert.Empty(t, st.Installed) + assert.Empty(t, st.Pending) + assert.NotNil(t, st.Installed, "maps are ready to use") + assert.NotNil(t, st.Rejected) + } + + prev := appConfig + t.Cleanup(func() { appConfig = prev }) + appConfig.OsqueryFlagFile = "/etc/osquery/osquery.flags" + assert.Equal(t, "/etc/osquery/osquery.flags.osctrld-extensions-state.json", extStatePath()) +} + +// The autoload path comes from served flags and osctrld writes it as root, so +// it must not name a file osctrld or osquery depend on, or a loose directory +func TestAutoloadPathRefused(t *testing.T) { + e := setupExt(t) + appConfig.OsquerySecretFile = filepath.Join(filepath.Dir(e.autoload), "osquery.secret") + appConfig.OsqueryCertFile = filepath.Join(filepath.Dir(e.autoload), "osctrl.crt") + for _, p := range []string{appConfig.OsquerySecretFile, appConfig.OsqueryCertFile} { + require.NoError(t, os.WriteFile(p, []byte("keep"), 0600)) + } + require.NoError(t, saveFlagsState(flagsStatePath(), flagsState{})) + require.NoError(t, saveExtState(extStatePath(), extState{})) + hardlink := filepath.Join(filepath.Dir(e.autoload), "hardlink.load") + require.NoError(t, os.Link(appConfig.OsqueryFlagFile, hardlink)) + require.NoError(t, os.MkdirAll(e.dir, 0755)) + + refused := map[string]string{ + "flagfile": appConfig.OsqueryFlagFile, + "flagfile uncleaned": filepath.Dir(appConfig.OsqueryFlagFile) + "/./" + filepath.Base(appConfig.OsqueryFlagFile), + "hardlink": hardlink, + "secret": appConfig.OsquerySecretFile, + "cert": appConfig.OsqueryCertFile, + "flags state": flagsStatePath(), + "extensions state": extStatePath(), + "extensions dir": filepath.Join(e.dir, "x.ext"), + "relative": "extensions.load", + } + for name, path := range refused { + t.Run(name, func(t *testing.T) { + before, _ := os.ReadFile(path) + assert.Error(t, writeAutoload(path, []string{"/e/x.ext"}, nil)) + after, _ := os.ReadFile(path) + assert.Equal(t, before, after, "left alone") + }) + } + + t.Run("loose directory", func(t *testing.T) { + fakeDirSafe(t, false) + assert.Error(t, writeAutoload(e.autoload, []string{"/e/x.ext"}, nil)) + assert.NoFileExists(t, e.autoload) + }) + + t.Run("step skips changes and reports", func(t *testing.T) { + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, + []byte(testOverrideFlags+"\n--extensions_autoload="+appConfig.OsquerySecretFile), 0700)) + e.srv.serve("x", "x-v1") + changed, err := syncExtensionsStep(context.Background()) + assert.Error(t, err) + assert.False(t, changed) + assert.NoFileExists(t, e.extPath(t, "x")) + b, err := os.ReadFile(appConfig.OsquerySecretFile) + require.NoError(t, err) + assert.Equal(t, "keep", string(b)) + st := readExtState(t) + assert.Empty(t, st.Installed) + assert.Empty(t, st.Pending) + s := statusFor(st, "x") + require.NotNil(t, s) + assert.Equal(t, applyRejected, s.Result) + assert.Contains(t, s.Error, "autoload") + }) +} + +func TestWriteAutoloadKeepsMode(t *testing.T) { + fakeSetOwner(t) + fakeDirSafe(t, true) + dir := t.TempDir() + fakeOsqueryPath(t, dir) + path := filepath.Join(dir, "extensions.load") + require.NoError(t, os.WriteFile(path, []byte("/opt/a.ext\n"), 0600)) + require.NoError(t, os.Chmod(path, 0640)) + require.NoError(t, writeAutoload(path, []string{"/e/d.ext"}, nil)) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0640), info.Mode().Perm()) +} + +// osctrld's autoload lines are reconciled every sync: a moved or edited +// autoload file is fixed, and osquery restarted and checked +func TestAutoloadDriftRepaired(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + e.setRunning(x, true) + require.NoError(t, os.WriteFile(e.autoload, []byte("/opt/operator.ext\n"), 0644)) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + assert.Equal(t, e.autoload, readExtState(t).Autoload) + + // The flags move the autoload file: lines follow, the old file keeps only + // the operator's lines + moved := filepath.Join(filepath.Dir(e.autoload), "moved.load") + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, + []byte(testOverrideFlags+"\n--extensions_autoload="+moved), 0700)) + changed, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + assert.True(t, changed, "needs a restart and a check") + b, err := os.ReadFile(moved) + require.NoError(t, err) + assert.Equal(t, x+"\n", string(b)) + assert.Equal(t, []string{"/opt/operator.ext"}, e.autoloadLines(t)) + assert.Equal(t, moved, readExtState(t).Autoload) + require.NoError(t, verifyExtensionsAfterRestart(context.Background())) + assert.Equal(t, applyOK, statusFor(readExtState(t), "x").Result) + assert.FileExists(t, x) + + // Deleted behind osctrld's back: written again + require.NoError(t, os.Remove(moved)) + assert.True(t, e.stepAndVerify(t)) + b, err = os.ReadFile(moved) + require.NoError(t, err) + assert.Equal(t, x+"\n", string(b)) + + // In place: nothing to do + assert.False(t, e.stepAndVerify(t)) + assert.Empty(t, *e.restarts) +} + +// Environment admins edit flags, so the autoload path may only name a .load +// file directly in osquery's base directory, never e.g. /etc/sudoers +func TestAutoloadPathOutsideBase(t *testing.T) { + e := setupExt(t) + base := filepath.Dir(e.autoload) + require.NoError(t, os.MkdirAll(filepath.Join(base, "sub"), 0755)) + for _, path := range []string{ + "/etc/ld.so.preload", + "/etc/sudoers", + filepath.Join(base, "sub", "x.load"), + filepath.Join(base, "x.txt"), + filepath.Join(base, "x.load.txt"), + "/tmp/x.load", + } { + t.Run(path, func(t *testing.T) { + before, _ := os.ReadFile(path) + assert.Error(t, writeAutoload(path, []string{"/e/x.ext"}, nil)) + after, _ := os.ReadFile(path) + assert.Equal(t, before, after, "left alone") + }) + } + + custom := filepath.Join(base, "custom.load") + require.NoError(t, writeAutoload(custom, []string{"/e/x.ext"}, nil)) + assert.FileExists(t, custom) + + appConfig.OsqueryPath = "" + assert.Error(t, checkAutoloadPath(custom), "no base directory configured, nothing accepted") + + // Windows paths are case-insensitive + appConfig.OsqueryPath = "/Program Files/Osquery/" + assert.Error(t, checkAutoloadPath("/program files/osquery/Custom.LOAD")) + extensionsGOOS = WindowsOS + assert.NoError(t, checkAutoloadPath("/program files/osquery/Custom.LOAD")) + assert.Error(t, checkAutoloadPath("/program files/osquery/sub/custom.load")) +} + +// A refused autoload path is reported for every served extension, once per path +func TestRefusedAutoloadReportedOnce(t *testing.T) { + e := setupExt(t) + x, y := e.extPath(t, "x"), e.extPath(t, "y") + e.setRunning(x, true) + e.setRunning(y, true) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + _, err := syncExtensionsStep(context.Background()) // sends and clears x's ok + require.NoError(t, err) + + e.srv.serve("x", "x-v1", "y", "y-v1") + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(e.flagfile("--extensions_autoload=/etc/sudoers")), 0700)) + for i := 0; i < 3; i++ { + changed, err := syncExtensionsStep(context.Background()) + assert.Error(t, err) + assert.False(t, changed) + } + assert.NoFileExists(t, y) + reqs := e.srv.recorded() + require.Len(t, reqs, 5) + assert.Empty(t, reqs[2].Status) + require.Len(t, reqs[3].Status, 2, "every served extension, installed or not") + for _, st := range reqs[3].Status { + assert.Equal(t, applyRejected, st.Result) + assert.Contains(t, st.Error, "/etc/sudoers") + } + assert.Empty(t, reqs[4].Status, "not repeated for the same path") + assert.Equal(t, "/etc/sudoers", readExtState(t).RefusedAutoload) + + // Fixed: the normal flow resumes and the memory is cleared + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(e.flagfile("")), 0700)) + assert.True(t, e.stepAndVerify(t)) + assert.FileExists(t, y) + assert.Equal(t, []string{x, y}, e.autoloadLines(t)) + assert.Empty(t, readExtState(t).RefusedAutoload) +} + +// An update whose process never runs puts the last good version back instead +// of leaving the host without the extension +func TestVerifyExtensionsUpdateFailsKeepsPrevious(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + e.setRunning(x, true) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + + e.setRunning(x, false) + e.srv.serve("x", "x-v2") + assert.True(t, e.stepAndVerify(t)) + + b, err := os.ReadFile(x) + require.NoError(t, err) + assert.Equal(t, "x-v1", string(b), "the last good version is back") + assert.NoFileExists(t, x+".prev") + assert.Equal(t, []string{x}, e.autoloadLines(t)) + assert.Len(t, *e.restarts, 1, "one restart with the last good version") + st := readExtState(t) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, st.Installed) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v2")}, st.Rejected) + assert.Equal(t, []string{x}, st.Lines) + assert.Empty(t, st.Pending) + assert.Empty(t, st.Previous) + s := statusFor(st, "x") + require.NotNil(t, s) + assert.Equal(t, applyRejected, s.Result) + assert.Equal(t, sha256Hex("x-v2"), s.SHA256) + assert.Equal(t, extProcessDownError(), s.Error) + assert.Empty(t, tempLeftovers(t, e.dir)) + + // v2 still served: nothing changes, v1 keeps running + e.setRunning(x, true) + *e.restarts = nil + assert.False(t, e.stepAndVerify(t)) + b, err = os.ReadFile(x) + require.NoError(t, err) + assert.Equal(t, "x-v1", string(b)) + assert.Equal(t, []string{x}, e.autoloadLines(t)) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, readExtState(t).Installed) + assert.Empty(t, *e.restarts) +} + +// A status error is capped so a long path cannot grow the request +func TestStatusErrorCapped(t *testing.T) { + long := strings.Repeat("é", 200) // 400 bytes + s := newStatus("x", sha256Hex("x"), applyRejected, long) + assert.LessOrEqual(t, len(s.Error), maxStatusError) + assert.True(t, utf8.ValidString(s.Error), "cut on a rune boundary") + assert.True(t, strings.HasPrefix(long, s.Error)) + assert.Equal(t, "short", newStatus("x", "", applyOK, "short").Error) +} + +// Only the newest statuses are sent, so the request stays under osctrl-tls's +// 16 KiB body limit +func TestStatusCountCapped(t *testing.T) { + e := setupExt(t) + var queued []ExtensionStatus + for i := 0; i < 100; i++ { + queued = append(queued, newStatus(fmt.Sprintf("s%03d", i), sha256Hex("x"), applyRejected, "e")) + } + require.NoError(t, saveExtState(extStatePath(), extState{Status: queued})) + _, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + reqs := e.srv.recorded() + require.Len(t, reqs, 1) + require.Len(t, reqs[0].Status, maxStatusPerRequest) + assert.Equal(t, "s099", reqs[0].Status[maxStatusPerRequest-1].Name, "the newest are kept") + assert.Equal(t, fmt.Sprintf("s%03d", 100-maxStatusPerRequest), reqs[0].Status[0].Name) + assert.Empty(t, readExtState(t).Status) + + // The largest request osctrld sends fits the body limit + full := ExtensionsRequest{OsctrlSecret: strings.Repeat("s", 64), UUID: strings.Repeat("u", 36), Platform: "windows", Arch: "arm64"} + for i := 0; i < maxStatusPerRequest; i++ { + full.Status = append(full.Status, newStatus(strings.Repeat("n", 64), sha256Hex("x"), applyRejected, strings.Repeat("e", 1000))) + } + b, err := json.Marshal(full) + require.NoError(t, err) + assert.Less(t, len(b), 16*1024) +} + +// osctrl refusing the request as malformed (400) or too large (413) drops the +// queued status, which would otherwise be refused forever; 401/403 (e.g. a +// rotated secret), network errors and 5xx keep it +func TestStatusDroppedOnClientError(t *testing.T) { + queued := []ExtensionStatus{newStatus("x", sha256Hex("x-v1"), applyOK, "")} + for _, tt := range []struct { + name string + code int + keep bool + }{ + {"too large", http.StatusRequestEntityTooLarge, false}, + {"bad request", http.StatusBadRequest, false}, + {"unauthorized", http.StatusUnauthorized, true}, + {"forbidden", http.StatusForbidden, true}, + {"server error", http.StatusInternalServerError, true}, + {"bad gateway", http.StatusBadGateway, true}, + {"network error", -1, true}, + } { + t.Run(tt.name, func(t *testing.T) { + e := setupExt(t) + require.NoError(t, saveExtState(extStatePath(), extState{Status: queued})) + if tt.code == -1 { + down := httptest.NewServer(http.NotFoundHandler()) + down.Close() + osctrlURLs.Extensions = down.URL + } else { + e.srv.listCode = tt.code + } + _, err := syncExtensionsStep(context.Background()) + assert.Error(t, err) + if tt.keep { + assert.Len(t, readExtState(t).Status, 1) + } else { + assert.Empty(t, readExtState(t).Status) + } + }) + } +} + +// A removal undone because the autoload file could not be written leaves the +// extension installed and is reported as kept +func TestRemovalAutoloadFailsKept(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + e.setRunning(x, true) + e.srv.serve("x", "x-v1") + assert.True(t, e.stepAndVerify(t)) + setOwner = func(path string) error { + if strings.Contains(filepath.Base(path), "extensions.load.tmp-") { + return errors.New("chown refused") + } + return nil + } + + e.srv.serve() + _, err := syncExtensionsStep(context.Background()) + require.Error(t, err) + assert.FileExists(t, x) + st := readExtState(t) + assert.Equal(t, map[string]string{"x": sha256Hex("x-v1")}, st.Installed) + s := statusFor(st, "x") + require.NotNil(t, s) + assert.Equal(t, applyKept, s.Result) + assert.Equal(t, sha256Hex("x-v1"), s.SHA256) + assert.Contains(t, s.Error, "chown refused") +} + +// Only the newest status per name is sent, before the count cap applies +func TestStatusDeduped(t *testing.T) { + e := setupExt(t) + var queued []ExtensionStatus + for i := 0; i < 100; i++ { + queued = append(queued, newStatus("x", sha256Hex("x"), applyRejected, fmt.Sprintf("e%d", i))) + } + queued = append(queued, newStatus("y", sha256Hex("y"), applyOK, ""), newStatus("x", sha256Hex("x"), applyKept, "last")) + require.NoError(t, saveExtState(extStatePath(), extState{Status: queued})) + _, err := syncExtensionsStep(context.Background()) + require.NoError(t, err) + reqs := e.srv.recorded() + require.Len(t, reqs, 1) + require.Len(t, reqs[0].Status, 2) + assert.Equal(t, "y", reqs[0].Status[0].Name) + assert.Equal(t, "x", reqs[0].Status[1].Name) + assert.Equal(t, applyKept, reqs[0].Status[1].Result, "the newest wins") + assert.Equal(t, "last", reqs[0].Status[1].Error) +} + +// The autoload path is used as validated: /x/../extensions.load names +// /extensions.load even when x is a symlink elsewhere +func TestAutoloadCleanedPath(t *testing.T) { + e := setupExt(t) + base := filepath.Dir(e.autoload) + elsewhere := filepath.Join(t.TempDir(), "a", "b") + require.NoError(t, os.MkdirAll(elsewhere, 0755)) + require.NoError(t, os.Symlink(elsewhere, filepath.Join(base, "x"))) + spelled := base + "/x/../extensions.load" + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(testOverrideFlags+"\n--extensions_autoload="+spelled), 0700)) + y := e.extPath(t, "y") + e.setRunning(y, true) + + e.srv.serve("y", "y-v1") + assert.True(t, e.stepAndVerify(t)) + assert.Equal(t, []string{y}, e.autoloadLines(t)) + assert.NoFileExists(t, filepath.Join(filepath.Dir(elsewhere), "extensions.load")) + assert.Equal(t, e.autoload, readExtState(t).Autoload, "stored cleaned") + + // Another spelling of the same file changes nothing + require.NoError(t, os.WriteFile(appConfig.OsqueryFlagFile, []byte(testOverrideFlags+"\n--extensions_autoload="+base+"//extensions.load"), 0700)) + assert.False(t, e.stepAndVerify(t)) + assert.Empty(t, *e.restarts) +} diff --git a/cmd/osctrld/flagsguard.go b/cmd/osctrld/flagsguard.go index 8baf2bc..a555a45 100644 --- a/cmd/osctrld/flagsguard.go +++ b/cmd/osctrld/flagsguard.go @@ -69,12 +69,17 @@ func loadFlagsState(path string) flagsState { } func saveFlagsState(path string, s flagsState) error { - b, err := json.Marshal(s) + return writeStateFile(path, s) +} + +// writeStateFile writes v as JSON, readable by root only +func writeStateFile(path string, v any) error { + b, err := json.Marshal(v) if err != nil { - return fmt.Errorf("error encoding flags state - %v", err) + return fmt.Errorf("error encoding state - %v", err) } if err := os.WriteFile(path, b, 0600); err != nil { - return fmt.Errorf("error writing flags state to %s - %v", path, err) + return fmt.Errorf("error writing state to %s - %v", path, err) } // WriteFile only applies the mode when it creates the file if err := os.Chmod(path, 0600); err != nil { @@ -190,8 +195,25 @@ func resumeFlagsCheck(ctx context.Context) error { // waitForOsqueryd polls until osqueryd has been seen on runningChecks // consecutive polls, or restartCheckTimeout passes func waitForOsqueryd(ctx context.Context) (bool, error) { - deadline := time.After(restartCheckTimeout) seen := 0 + return pollUntil(ctx, func() bool { + running, err := osquerydRunning() + if err != nil { + log.Debug().Err(err).Msg("error checking osqueryd") + } + if !running { + seen = 0 + return false + } + seen++ + return seen >= runningChecks + }) +} + +// pollUntil calls check every restartCheckInterval until it returns true, or +// restartCheckTimeout passes +func pollUntil(ctx context.Context, check func() bool) (bool, error) { + deadline := time.After(restartCheckTimeout) for { select { case <-ctx.Done(): @@ -200,15 +222,7 @@ func waitForOsqueryd(ctx context.Context) (bool, error) { return false, nil case <-time.After(restartCheckInterval): } - running, err := osquerydRunning() - if err != nil { - log.Debug().Err(err).Msg("error checking osqueryd") - } - if !running { - seen = 0 - continue - } - if seen++; seen >= runningChecks { + if check() { return true, nil } } diff --git a/cmd/osctrld/http-utils.go b/cmd/osctrld/http-utils.go index ae42d27..1a32608 100644 --- a/cmd/osctrld/http-utils.go +++ b/cmd/osctrld/http-utils.go @@ -43,17 +43,18 @@ const osctrlUserAgent string = "osctrld-http-client/" + OsctrldVersion // body timeout if a stalled server must be detected faster. var httpTimeout = 10 * time.Minute -// SendRequest - Helper function to send HTTP requests -func SendRequest(reqType, reqURL string, params io.Reader, headers map[string]string, insecure bool) (int, []byte, error) { +// SendRequestStream sends an HTTP request like SendRequest but hands back the +// response unread, so a large body can be streamed. The caller closes the body. +func SendRequestStream(reqType, reqURL string, params io.Reader, headers map[string]string, insecure bool) (*http.Response, error) { u, err := url.Parse(reqURL) if err != nil { - return 0, nil, fmt.Errorf("invalid url: %v", err) + return nil, fmt.Errorf("invalid url: %v", err) } client := &http.Client{Timeout: httpTimeout} if u.Scheme == "https" { certPool, err := x509.SystemCertPool() if err != nil { - return 0, nil, fmt.Errorf("error loading x509 certificate pool: %v", err) + return nil, fmt.Errorf("error loading x509 certificate pool: %v", err) } tlsCfg := &tls.Config{RootCAs: certPool} if insecure { @@ -63,7 +64,7 @@ func SendRequest(reqType, reqURL string, params io.Reader, headers map[string]st } req, err := http.NewRequest(reqType, reqURL, params) if err != nil { - return 0, []byte("Could not prepare request"), err + return nil, fmt.Errorf("could not prepare request: %v", err) } // Set custom User-Agent req.Header.Set(UserAgent, osctrlUserAgent) @@ -71,8 +72,12 @@ func SendRequest(reqType, reqURL string, params io.Reader, headers map[string]st for key, value := range headers { req.Header.Add(key, value) } - // Send request - resp, err := client.Do(req) + return client.Do(req) +} + +// SendRequest - Helper function to send HTTP requests +func SendRequest(reqType, reqURL string, params io.Reader, headers map[string]string, insecure bool) (int, []byte, error) { + resp, err := SendRequestStream(reqType, reqURL, params, headers, insecure) if err != nil { return 0, []byte("Error sending request"), err } diff --git a/cmd/osctrld/owner_posix.go b/cmd/osctrld/owner_posix.go new file mode 100644 index 0000000..1539d80 --- /dev/null +++ b/cmd/osctrld/owner_posix.go @@ -0,0 +1,36 @@ +//go:build !windows + +package main + +import ( + "io/fs" + "os" + "syscall" +) + +// setOwnerRoot hands path to root:root without following a symlink +func setOwnerRoot(path string) error { + return os.Lchown(path, 0, 0) +} + +// adminOwnedFile reports whether path is a regular file owned by root at +// exactly 0755, so nobody but root can have rewritten it or rewrite it later +func adminOwnedFile(path string) bool { + info, err := os.Lstat(path) + if err != nil || info.Mode() != 0755 { + return false + } + st, ok := info.Sys().(*syscall.Stat_t) + return ok && st.Uid == 0 +} + +// adminOwnedDir reports whether path is a real directory (not a symlink) owned +// by root that neither group nor others can write +func adminOwnedDir(path string) bool { + info, err := os.Lstat(path) + if err != nil || info.Mode().Type() != fs.ModeDir || info.Mode().Perm()&0022 != 0 { + return false + } + st, ok := info.Sys().(*syscall.Stat_t) + return ok && st.Uid == 0 +} diff --git a/cmd/osctrld/owner_posix_test.go b/cmd/osctrld/owner_posix_test.go new file mode 100644 index 0000000..b3b7265 --- /dev/null +++ b/cmd/osctrld/owner_posix_test.go @@ -0,0 +1,75 @@ +//go:build !windows + +package main + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExtensionFileSafePOSIX(t *testing.T) { + dir := t.TempDir() + file := func(name string, mode os.FileMode) string { + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, []byte("x"), 0600)) + require.NoError(t, os.Chmod(p, mode)) + return p + } + asRoot := os.Geteuid() == 0 + + assert.False(t, adminOwnedFile(file("writable", 0777)), "group/world-writable is never safe") + assert.False(t, adminOwnedFile(file("setuid", 0755|os.ModeSetuid)), "special bits are not 0755") + assert.Equal(t, asRoot, adminOwnedFile(file("good", 0755)), "0755 is safe only when owned by root") + link := filepath.Join(dir, "link") + require.NoError(t, os.Symlink(filepath.Join(dir, "good"), link)) + assert.False(t, adminOwnedFile(link), "a symlink is not followed") + assert.False(t, adminOwnedFile(filepath.Join(dir, "missing"))) +} + +func TestAdminOwnedDirPOSIX(t *testing.T) { + dir := t.TempDir() + asRoot := os.Geteuid() == 0 + require.NoError(t, os.Chmod(dir, 0755)) + assert.Equal(t, asRoot, adminOwnedDir(dir), "0755 is safe only when owned by root") + require.NoError(t, os.Chmod(dir, 0775)) + assert.False(t, adminOwnedDir(dir), "group-writable is never safe") + require.NoError(t, os.Chmod(dir, 0755)) + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(dir, link)) + assert.False(t, adminOwnedDir(link), "a symlink is not followed") + assert.False(t, adminOwnedDir(filepath.Join(dir, "missing"))) +} + +// A FIFO where an extension goes is refused, never opened (which would block) +func TestSyncExtensionsRefusesFIFO(t *testing.T) { + e := setupExt(t) + x := e.extPath(t, "x") + require.NoError(t, os.MkdirAll(e.dir, 0755)) + require.NoError(t, syscall.Mkfifo(x, 0644)) + e.srv.serve("x", "x-v1") + + done := make(chan error, 1) + go func() { + _, err := syncExtensionsStep(context.Background()) + done <- err + }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("syncExtensionsStep blocked on the FIFO") + } + s := statusFor(readExtState(t), "x") + require.NotNil(t, s) + assert.Equal(t, applyRejected, s.Result) + info, err := os.Lstat(x) + require.NoError(t, err) + assert.Equal(t, os.ModeNamedPipe, info.Mode().Type(), "left alone") +} diff --git a/cmd/osctrld/owner_windows.go b/cmd/osctrld/owner_windows.go new file mode 100644 index 0000000..220a6c4 --- /dev/null +++ b/cmd/osctrld/owner_windows.go @@ -0,0 +1,75 @@ +//go:build windows + +package main + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +// fileAllAccess is FILE_ALL_ACCESS, absent from x/sys/windows +const fileAllAccess windows.ACCESS_MASK = windows.STANDARD_RIGHTS_REQUIRED | windows.SYNCHRONIZE | 0x1FF + +// extensionDACL grants full control to Administrators and SYSTEM and read and +// execute to Users, and nothing else +func extensionDACL() (*windows.ACL, error) { + grants := []struct { + sid windows.WELL_KNOWN_SID_TYPE + mask windows.ACCESS_MASK + }{ + {windows.WinBuiltinAdministratorsSid, fileAllAccess}, + {windows.WinLocalSystemSid, fileAllAccess}, + {windows.WinBuiltinUsersSid, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE}, + } + entries := make([]windows.EXPLICIT_ACCESS, 0, len(grants)) + for _, g := range grants { + sid, err := windows.CreateWellKnownSid(g.sid) + if err != nil { + return nil, err + } + entries = append(entries, windows.EXPLICIT_ACCESS{ + AccessPermissions: g.mask, + AccessMode: windows.GRANT_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_WELL_KNOWN_GROUP, + TrusteeValue: windows.TrusteeValueFromSID(sid), + }, + }) + } + return windows.ACLFromEntries(entries, nil) +} + +// setOwnerRoot makes Administrators the owner of path and replaces its whole +// DACL with extensionDACL, protected from inheritance, in one call. The owner +// is an administrator group, so its implicit WRITE_DAC grants nobody else write. +func setOwnerRoot(path string) error { + admins, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return err + } + dacl, err := extensionDACL() + if err != nil { + return err + } + if err := windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, + windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + admins, nil, dacl, nil); err != nil { + return fmt.Errorf("error setting security on %s - %v", path, err) + } + return nil +} + +// adminOwnedFile re-applies the owner and DACL to an existing file and trusts +// it only if that succeeds, rather than parsing its current security descriptor +func adminOwnedFile(path string) bool { + return setOwnerRoot(path) == nil +} + +// adminOwnedDir is not checked on Windows: osquery's directories under +// Program Files are writable by administrators only by default. +// ponytail: read the directory's DACL with GetNamedSecurityInfo if hosts move +// the autoload file somewhere looser. +func adminOwnedDir(string) bool { return true } diff --git a/cmd/osctrld/owner_windows_test.go b/cmd/osctrld/owner_windows_test.go new file mode 100644 index 0000000..252aa36 --- /dev/null +++ b/cmd/osctrld/owner_windows_test.go @@ -0,0 +1,51 @@ +//go:build windows + +package main + +import ( + "os/exec" + "path/filepath" + "testing" + "unsafe" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestExtensionDACL(t *testing.T) { + acl, err := extensionDACL() + require.NoError(t, err) + want := []struct { + sid windows.WELL_KNOWN_SID_TYPE + mask windows.ACCESS_MASK + }{ + {windows.WinBuiltinAdministratorsSid, fileAllAccess}, + {windows.WinLocalSystemSid, fileAllAccess}, + {windows.WinBuiltinUsersSid, windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE}, + } + require.Equal(t, uint16(len(want)), acl.AceCount, "no entry beyond the three granted") + got := map[string]windows.ACCESS_MASK{} + for i := uint32(0); i < uint32(acl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + require.NoError(t, windows.GetAce(acl, i, &ace)) + assert.Equal(t, uint8(windows.ACCESS_ALLOWED_ACE_TYPE), ace.Header.AceType) + assert.Zero(t, ace.Header.AceFlags, "no inheritance flags") + got[(*windows.SID)(unsafe.Pointer(&ace.SidStart)).String()] = ace.Mask + } + for _, w := range want { + sid, err := windows.CreateWellKnownSid(w.sid) + require.NoError(t, err) + assert.Equal(t, w.mask, got[sid.String()], sid.String()) + } +} + +// A junction reports ModeDir with ModeIrregular: it is not a real directory +func TestEnsureExtensionsDirRefusesJunction(t *testing.T) { + owned := fakeSetOwner(t) + target := t.TempDir() + link := filepath.Join(t.TempDir(), "extensions") + require.NoError(t, exec.Command("cmd", "/c", "mklink", "/J", link, target).Run()) + assert.Error(t, ensureExtensionsDir(link)) + assert.Empty(t, *owned) +} diff --git a/cmd/osctrld/service.go b/cmd/osctrld/service.go index eed897b..2e5302a 100644 --- a/cmd/osctrld/service.go +++ b/cmd/osctrld/service.go @@ -23,7 +23,7 @@ func syncOnce(ctx context.Context, cmd *cli.Command) { appConfig.Force = true defer func() { appConfig.Force = originalForce }() - var flagsChanged, certChanged, extensionsChanged bool + var flagsChanged, certChanged bool if err := resumeFlagsCheck(ctx); err != nil { log.Error().Err(err).Msg("failed to resume flags check") @@ -45,17 +45,10 @@ func syncOnce(ctx context.Context, cmd *cli.Command) { certChanged = changed } - if changed, err := syncExtensions(); err != nil { - log.Error().Err(err).Msg("failed to sync extensions") - } else { - extensionsChanged = changed - } - - if flagsChanged || certChanged || extensionsChanged { + if flagsChanged || certChanged { log.Info(). Bool("flags_changed", flagsChanged). Bool("cert_changed", certChanged). - Bool("extensions_changed", extensionsChanged). Msg("configuration changed, restarting osquery") if err := restartOsquery(); err != nil { // Still verify: osqueryd may be down with the new flags either way @@ -67,6 +60,31 @@ func syncOnce(ctx context.Context, cmd *cli.Command) { } } } + + // Extensions go second, with their own restart and check, so a failure is + // blamed on the right change and a bad extension never rolls back good flags + if ctx.Err() != nil { + return + } + if err := resumeExtensionsCheck(ctx); err != nil { + log.Error().Err(err).Msg("failed to resume extensions check") + if ctx.Err() != nil { + return + } + } + log.Info().Msg("syncing extensions") + extensionsChanged, err := syncExtensionsStep(ctx) + if err != nil { + log.Error().Err(err).Msg("failed to sync extensions") + } + if extensionsChanged { + log.Info().Msg("extensions changed, restarting osquery") + // Still verifies after a failed restart: osqueryd may be down either way + restartForExtensions() + if err := verifyExtensionsAfterRestart(ctx); err != nil { + log.Error().Err(err).Msg("failed to verify extensions after restart") + } + } } func serviceNode(ctx context.Context, cmd *cli.Command) error { diff --git a/cmd/osctrld/utils.go b/cmd/osctrld/utils.go index c960c0e..f3bcec3 100644 --- a/cmd/osctrld/utils.go +++ b/cmd/osctrld/utils.go @@ -20,6 +20,8 @@ const ( OsctrlURLScript = "%s/%s/%s/osctrld-script" // OsctrlURLExtensions to send request for extensions OsctrlURLExtensions = "%s/osctrld-extensions" + // OsctrlURLExtension to download one extension, named by SHA-256 in the body + OsctrlURLExtension = "%s/osctrld-extension" // OsctrlEnroll to identify enrolls OsctrlEnroll = "enroll" // OsctrlRemove to identify removals