Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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 `<osqueryFlagFile>.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 <base>/<env>/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 `<name>.ext` (`<name>.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 (`<file>.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
`<osqueryFlagFile>.osctrld-extensions-state.json`.

## Native enrollment

`osctrld install` does everything the osctrl quick-add script does, without a shell:
Expand Down
28 changes: 25 additions & 3 deletions cmd/osctrld/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
221 changes: 179 additions & 42 deletions cmd/osctrld/extensions.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
Loading
Loading