From d6b7c18b4a07f00a49f75e23d990b3a8cd940f20 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:46:07 +0200 Subject: [PATCH] osquery extensions delivery --- ARCHITECTURE.md | 15 +- README.md | 1 + cmd/api/handlers/extensions.go | 448 +++++++ cmd/api/handlers/extensions_assign.go | 423 +++++++ cmd/api/handlers/extensions_assign_test.go | 432 +++++++ cmd/api/handlers/extensions_test.go | 361 ++++++ cmd/api/handlers/handlers.go | 9 + cmd/api/handlers/nodes.go | 5 + cmd/api/main.go | 48 + cmd/cli/node.go | 13 +- cmd/cli/node_delete_test.go | 21 +- cmd/tls/handlers/extensions.go | 154 +++ cmd/tls/handlers/extensions_test.go | 276 ++++ cmd/tls/handlers/handlers.go | 9 + cmd/tls/main.go | 115 +- cmd/tls/routes_test.go | 48 + deploy/cicd/nginx/frontend-tls.conf | 3 +- deploy/cicd/nginx/frontend.conf | 3 +- deploy/docker/conf/nginx/frontend-dev.conf | 3 +- deploy/nginx/frontend.conf | 3 +- frontend/src/api/extensions.ts | 96 ++ frontend/src/api/types.ts | 93 ++ .../src/components/chrome/SideNav.test.tsx | 22 + frontend/src/components/chrome/SideNav.tsx | 12 + .../environments/EnvConfigPage.test.tsx | 23 + .../features/environments/EnvConfigPage.tsx | 11 +- .../environments/EnvExtensionsTab.test.tsx | 104 ++ .../environments/EnvExtensionsTab.tsx | 105 ++ .../extensions/ExtensionsPage.test.tsx | 164 +++ .../features/extensions/ExtensionsPage.tsx | 368 ++++++ .../src/features/nodes/NodeConfigTab.test.tsx | 17 + frontend/src/features/nodes/NodeConfigTab.tsx | 27 +- .../features/nodes/NodeDetailPage.test.tsx | 138 +- .../src/features/nodes/NodeDetailPage.tsx | 48 +- .../nodes/NodeExtensionsView.test.tsx | 235 ++++ .../src/features/nodes/NodeExtensionsView.tsx | 289 +++++ frontend/src/i18n/locales/ar/common.ts | 2 + frontend/src/i18n/locales/ca/common.ts | 2 + frontend/src/i18n/locales/de/common.ts | 2 + frontend/src/i18n/locales/el/common.ts | 2 + frontend/src/i18n/locales/en/common.ts | 2 + frontend/src/i18n/locales/es/common.ts | 2 + frontend/src/i18n/locales/fa/common.ts | 2 + frontend/src/i18n/locales/fr/common.ts | 2 + frontend/src/i18n/locales/he/common.ts | 2 + frontend/src/i18n/locales/hi/common.ts | 2 + frontend/src/i18n/locales/it/common.ts | 2 + frontend/src/i18n/locales/ja/common.ts | 2 + frontend/src/i18n/locales/ko/common.ts | 2 + frontend/src/i18n/locales/nl/common.ts | 2 + frontend/src/i18n/locales/pl/common.ts | 2 + frontend/src/i18n/locales/pt/common.ts | 2 + frontend/src/i18n/locales/ru/common.ts | 2 + frontend/src/i18n/locales/tr/common.ts | 2 + frontend/src/i18n/locales/uk/common.ts | 2 + frontend/src/i18n/locales/zh/common.ts | 2 + frontend/src/router.tsx | 2 + frontend/src/routes/_app/extensions.tsx | 9 + osctrl-api.yaml | 1111 +++++++++++++++-- pkg/environments/environments.go | 6 + pkg/extensions/assign.go | 198 +++ pkg/extensions/blobs.go | 188 +++ pkg/extensions/extensions_test.go | 701 +++++++++++ pkg/extensions/manager.go | 250 ++++ pkg/extensions/models.go | 81 ++ pkg/extensions/status.go | 101 ++ pkg/types/types.go | 143 +++ 67 files changed, 6796 insertions(+), 176 deletions(-) create mode 100644 cmd/api/handlers/extensions.go create mode 100644 cmd/api/handlers/extensions_assign.go create mode 100644 cmd/api/handlers/extensions_assign_test.go create mode 100644 cmd/api/handlers/extensions_test.go create mode 100644 cmd/tls/handlers/extensions.go create mode 100644 cmd/tls/handlers/extensions_test.go create mode 100644 cmd/tls/routes_test.go create mode 100644 frontend/src/api/extensions.ts create mode 100644 frontend/src/features/environments/EnvExtensionsTab.test.tsx create mode 100644 frontend/src/features/environments/EnvExtensionsTab.tsx create mode 100644 frontend/src/features/extensions/ExtensionsPage.test.tsx create mode 100644 frontend/src/features/extensions/ExtensionsPage.tsx create mode 100644 frontend/src/features/nodes/NodeExtensionsView.test.tsx create mode 100644 frontend/src/features/nodes/NodeExtensionsView.tsx create mode 100644 frontend/src/routes/_app/extensions.tsx create mode 100644 pkg/extensions/assign.go create mode 100644 pkg/extensions/blobs.go create mode 100644 pkg/extensions/extensions_test.go create mode 100644 pkg/extensions/manager.go create mode 100644 pkg/extensions/models.go create mode 100644 pkg/extensions/status.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6dc923eae..106b68106 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -65,6 +65,7 @@ Main runtime components: - `pkg/health`: deployment health — the `service_status` heartbeat table, component status derivation (operational/degraded/down/stale/unknown), Go runtime snapshots, and a 24h-refreshed upgrade-version cache. Gated by `--health-enabled` (default off). - `pkg/nodes`: node registry, lookup, archive, metadata updates. - `pkg/nodeconfig`: per-node osquery configuration and flags overrides (validation, merge rules, Redis-backed caches) and the osctrld status record. +- `pkg/extensions`: osquery extension library (files up to 64 MB stored in the database as 1 MiB chunks shared by SHA-256), environment and host assignments, per-node status, and the serve-time enforcement of who may receive what. - `pkg/queries`: distributed query definitions, targets, node-query state, saved queries. - `pkg/carves`: file carving metadata and storage backends (`db`, `local`, `s3`). - `pkg/logging`: TLS log pipeline, sink implementations (`db`, `file`, `stdout`, `graylog`, `splunk`, `logstash`, `kinesis`, `s3`, `kafka`, `elastic`), and DB/S3 readers. @@ -325,6 +326,15 @@ Per-node configuration overrides are admin-only (environment admin, enforced ser Per-node flags overrides (`GET|PUT|DELETE /api/v1/nodes/{env}/node/{uuid}/flags`, same admin/audit/expiry rules) follow the same model for the osquery flagfile. The text is one flag per line (`--name` or `--name=value`); connection flags (including their `no` forms) are refused, while flags also set as configuration options are listed as conflicts (a warning, not refused). The override merges into the environment flags by name (replacing a flag in place, appending new ones). Only `osctrld` reads flags, and only in service mode, so the override reaches a host when osctrld's sync request carries that host's osquery UUID (`uuid`, from `system_info.uuid`) and the UUID matches the node: `osctrl-tls` then merges the active override into the osctrld `flags` and `verify` responses and records the fetch in `node_osctrld_status`. osctrld reports the outcome of its last flags change (`apply`); because osquery exits on a flag it rejects, osctrld checks that `osqueryd` stays running after a restart, otherwise restores the environment flags, restarts, reports `rejected` and does not re-apply that content while it is served. Flags apply on the next sync (default 60 minutes) with an osquery restart and fully revert on removal or expiry. Deleting a node (API or CLI) removes its overrides and osctrld status. + +### osquery extensions + +`pkg/extensions` delivers osquery extension binaries to hosts through `osctrld`. Only super admins manage the library under `/api/v1/extensions`: `POST` creates an extension (JSON), and a multipart `PUT /api/v1/extensions/{id}/files/{platform}/{arch}` (field `file`, ≤ 64 MB) uploads the binary for one platform/architecture. A file is stored in the database as 1 MiB chunks keyed by SHA-256, so identical content is stored once. Assignment follows the risk of the extension: environment admins assign ordinary extensions to an environment (`/api/v1/environments/extensions/{env}[/{id}]`), while extensions marked as executing commands are assigned only by super admins, per host and with an optional expiry (`/api/v1/nodes/{env}/node/{uuid}/extensions[/{id}]`; environment admins can view them, and the expiry key must be present in the request). The split is enforced when serving, not only in the API: an environment assignment never delivers a command-execution extension, and each host's list is computed per host (the environment's extensions plus that host's own unexpired command-execution assignments). Downloads are authorised by the environment secret and limited, by content hash, to files assigned in that environment, not per host. Extension names are unique ignoring case, since hosts may have case-insensitive file systems. Deleting a node removes its assignments and statuses; deleting an extension removes the statuses reported for it; removing a host assignment removes that host's status for it, unless the status is `kept`. `osctrld` reports a removal as `removed`, or as `kept` when it failed and the extension is still on the host (repeated every sync while it stays); a `kept` command-execution extension the host has no unexpired assignment for (removed or expired) is flagged on the node page, ahead of the expired wording, until a later report replaces it, and meanwhile the extension cannot be deleted from the library or have command execution switched off. + +`osctrl-tls`, under `osctrld.enabled`, serves `POST /{env}/osctrld-extensions` (the list for the host's UUID and platform/architecture, which also records the status `osctrld` reports; a request without platform and architecture, from an `osctrld` that predates extensions, gets an empty list) and `POST /{env}/osctrld-extension` (download, body `{secret, sha256}`; the hash must belong to a file assigned to the environment or, unexpired, to a host in it). Hosts must run `osctrld` in service mode and pick up changes on its next sync (default 60 minutes). `osctrld` verifies size and SHA-256, installs files root-owned, manages only its own autoload lines (the autoload file must be a `*.load` file directly in osquery's base directory, otherwise it is refused and reported), checks that each extension's process runs after the osquery restart, and rolls back failures and reports them; the node page shows the per-extension status. The operator UI exposes Admin › Extensions (library), the environment Configuration › Extensions tab and the node Configuration › Extensions view. + +Because uploads go through nginx, the `/api/` location in the frontend configs allows 65 MB bodies. The direct API vhost that `deploy/provision.sh` builds from `deploy/nginx/ssl.conf` inherits the global `client_max_body_size 20M` from `deploy/nginx/nginx.conf`, so uploads over 20 MB through it fail until `client_max_body_size` is raised for that vhost. + ### Steady-state database load - Node check-ins are coalesced per TLS process and persisted in parameterized bulk updates of at most 100 nodes. Each node retains its observation timestamp, normalized to database precision; older observations cannot replace newer timestamps or IPs. Equal stored timestamps keep the first persisted IP. The existing writer batch size, timeout, and buffer settings still control collection. The queue remains process-local and best-effort on shutdown or database errors; it is not a durable heartbeat store. @@ -384,6 +394,9 @@ Feature-owned tables are migrated only when their manager is initialized. In par - `pkg/nodeconfig.Override` -> `node_config_overrides` - `pkg/nodeconfig.FlagsOverride` -> `node_flags_overrides` - `pkg/nodeconfig.OsctrldStatus` -> `node_osctrld_status` +- Extensions: + - `pkg/extensions.Extension` -> `extensions`; `ExtensionFile` -> `extension_files`; `ExtensionChunk` -> `extension_chunks` + - `pkg/extensions.EnvExtension` -> `env_extensions`; `NodeExtension` -> `node_extensions`; `NodeExtensionStatus` -> `node_extension_statuses` - Users and auth: - `pkg/users.AdminUser` -> `admin_users` - `pkg/users.UserPermission` -> `user_permissions` @@ -445,7 +458,7 @@ Redis-only state such as activity rollups, query-dispatch hints, and alert coold - Production-oriented assets live under `deploy/`: - sample YAML configs in `deploy/config/*.yml` - systemd unit template in `deploy/config/systemd.service` - - nginx config in `deploy/nginx/` + - nginx config in `deploy/nginx/` (extension uploads need `client_max_body_size` of 65m on the API location; the direct API vhost from `provision.sh` inherits 20M, see the osquery extensions section) - Dockerfiles in `deploy/cicd/docker/` and `deploy/docker/dockerfiles/` - Tagged releases publish direct `osctrl-mcp` binaries for Linux, macOS, and Windows on amd64 and arm64. Branch CI retains snapshot MCP binaries as workflow artifacts. MCP is not currently shipped as a container or native OS package. - GoReleaser creates DEB and RPM packages for `osctrl-tls`, `osctrl-api`, and `osctrl-cli`. diff --git a/README.md b/README.md index 511310408..e76ee61fb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Whether you’re running a small deployment or managing large fleets, **osctrl** - **Modern operator UI**: React SPA powered by `osctrl-api`, with views for nodes, environments, queries, saved queries, carves, tags, users, enrollment, audit log, service configuration, log sinks, auth providers, alerting, node activity, and optional posture data. - **Node console**: Read-only console for a specific node using hidden accelerated distributed queries. It supports shell-like commands such as `pwd`, `cd`, `ls`, `stat`, `ps`, `sql`, `osquery`, `.tables`, and `get` for permission-checked file carves. - **Per-node configuration and flags overrides**: Environment admins can layer a JSON override on one node's osquery configuration, or a flags override on its flagfile, from the node page, with optional expiry. Connection settings are protected, every change is audited, and options the environment doesn't set keep their overridden value on the host until osquery restarts. Flags overrides reach hosts managed by osctrld's service mode on its next sync with an osquery restart; if osquery rejects them, osctrld rolls back and reports it. Removing or expiring a flags override reverts the host on osctrld's next sync. +- **osquery extensions**: Super admins upload extension binaries (up to 64 MB per platform) to a database-backed library. Environment admins assign ordinary extensions to an environment; super admins assign command-execution extensions to single hosts, with optional expiry. osctrld's service mode installs them on its next sync, verifies hashes, rolls back failures, and the node page shows per-extension status. - **File explorer**: Accelerated per-node directory listing and stat requests backed by osquery distributed queries. - **Accelerated distributed queries**: Optional osquery accelerated query reads, defaulting to a 5 second interval when enabled. Console acceleration is scoped to the target node and fresh active console sessions. - **osquery schema awareness**: Ships osquery table metadata through 5.23.1 and exposes authenticated table metadata to the UI/API for query authoring and console `.tables`. diff --git a/cmd/api/handlers/extensions.go b/cmd/api/handlers/extensions.go new file mode 100644 index 000000000..10d0015e8 --- /dev/null +++ b/cmd/api/handlers/extensions.go @@ -0,0 +1,448 @@ +package handlers + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" + "unicode" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" +) + +const ( + extensionDescriptionMax = 1024 + extensionFilenameMax = 255 + extensionJSONMaxBody = 64 << 10 + // extensionUploadMaxBody is the largest file plus room for the + // multipart framing. + extensionUploadMaxBody = extensions.MaxFileSize + 1<<20 + // extensionUploadTimeout bounds how long an upload, and the database + // transaction PutFile holds while reading it, can take. + extensionUploadTimeout = 10 * time.Minute +) + +// requireExtensionsAdmin allows super admins only and audits every denial. +func (h *HandlersApi) requireExtensionsAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissionsContext(r.Context(), ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + h.AuditLog.Denied(ctx[ctxUser], r.URL.Path, utils.GetIP(r), "permission check failed", auditlog.LogTypeSetting, auditlog.NoEnvironment) + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use extensions API by user %s", ctx[ctxUser])) + return "", false + } + return ctx[ctxUser], true +} + +// extensionsError maps pkg/extensions errors to HTTP statuses. +func extensionsError(w http.ResponseWriter, err error) { + var tooBig *http.MaxBytesError + switch { + case errors.Is(err, extensions.ErrTooLarge), errors.As(err, &tooBig): + apiErrorResponse(w, "file is larger than 64 MiB", http.StatusRequestEntityTooLarge, err) + case errors.Is(err, extensions.ErrInvalid), errors.Is(err, extensions.ErrEmpty), errors.Is(err, extensions.ErrTruncated): + apiErrorResponse(w, err.Error(), http.StatusBadRequest, err) + case errors.Is(err, extensions.ErrNotFound): + apiErrorResponse(w, "not found", http.StatusNotFound, err) + case errors.Is(err, extensions.ErrDuplicate): + apiErrorResponse(w, "an extension with that name already exists", http.StatusConflict, err) + case errors.Is(err, extensions.ErrAssigned): + apiErrorResponse(w, "extension is assigned to environments or nodes", http.StatusConflict, err) + case errors.Is(err, extensions.ErrKept): + apiErrorResponse(w, "a host could not remove this command-execution extension", http.StatusConflict, err) + case errors.Is(err, extensions.ErrCommandExecution), errors.Is(err, extensions.ErrNotCommandExecution): + apiErrorResponse(w, err.Error(), http.StatusConflict, err) + default: + log.Err(err).Msg("extensions API error") + apiErrorResponse(w, "internal error", http.StatusInternalServerError, err) + } +} + +func toExtensionView(v extensions.ExtensionView) types.ExtensionView { + files := make([]types.ExtensionFileView, 0, len(v.Files)) + for _, f := range v.Files { + files = append(files, toExtensionFileView(f)) + } + return types.ExtensionView{ + ID: v.ID, + Name: v.Name, + Description: v.Description, + Version: v.Version, + CommandExecution: v.CommandExecution, + Files: files, + Environments: int(v.EnvAssignments), + Nodes: int(v.NodeAssignments), + CreatedBy: v.CreatedBy, + UpdatedAt: v.UpdatedAt, + } +} + +func toExtensionFileView(f extensions.ExtensionFile) types.ExtensionFileView { + return types.ExtensionFileView{ + Platform: f.Platform, + Arch: f.Arch, + Size: f.Size, + SHA256: f.SHA256, + Filename: f.Filename, + UpdatedAt: f.UpdatedAt, + UpdatedBy: f.UpdatedBy, + } +} + +// extensionView returns one library entry with its files and counts. +// ponytail: scans the whole library; add a per-ID query if it ever grows large. +func (h *HandlersApi) extensionView(id uint) (types.ExtensionView, error) { + views, err := h.Extensions.List() + if err != nil { + return types.ExtensionView{}, err + } + for _, v := range views { + if v.ID == id { + return toExtensionView(v), nil + } + } + return types.ExtensionView{}, extensions.ErrNotFound +} + +// sanitizeFilename keeps the base name of an uploaded file, without control +// characters and at most extensionFilenameMax bytes of valid UTF-8. +func sanitizeFilename(name string) string { + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, name) + name = path.Base(strings.ReplaceAll(name, `\`, "/")) + if name == "." || name == ".." || name == "/" { + return "" + } + if len(name) > extensionFilenameMax { + name = strings.ToValidUTF8(name[:extensionFilenameMax], "") + } + return name +} + +// ExtensionsListHandler - GET Handler for the extensions library +// @Summary List osquery extensions +// @Description Returns the extensions library with each extension's files and assignment counts. Super admins only. +// @Tags extensions +// @Produce json +// @Success 200 {array} types.ExtensionView +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/extensions [get] +func (h *HandlersApi) ExtensionsListHandler(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireExtensionsAdmin(w, r); !ok { + return + } + views, err := h.Extensions.List() + if err != nil { + extensionsError(w, err) + return + } + out := make([]types.ExtensionView, 0, len(views)) + for _, v := range views { + out = append(out, toExtensionView(v)) + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} + +// ExtensionsCreateHandler - POST Handler to add an extension +// @Summary Create an osquery extension +// @Description Adds an extension to the library. Command-execution extensions can only be assigned to individual nodes. Super admins only. +// @Tags extensions +// @Accept json +// @Produce json +// @Param request body types.ExtensionCreateRequest true "Extension" +// @Success 201 {object} types.ExtensionView +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 409 {object} types.ApiErrorResponse "Name already exists" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/extensions [post] +func (h *HandlersApi) ExtensionsCreateHandler(w http.ResponseWriter, r *http.Request) { + user, ok := h.requireExtensionsAdmin(w, r) + if !ok { + return + } + var req types.ExtensionCreateRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, extensionJSONMaxBody)).Decode(&req); err != nil { + apiErrorResponse(w, "error parsing request body", http.StatusBadRequest, err) + return + } + if len(req.Description) > extensionDescriptionMax { + apiErrorResponse(w, fmt.Sprintf("description is longer than %d bytes", extensionDescriptionMax), http.StatusBadRequest, nil) + return + } + e, err := h.Extensions.Create(extensions.Extension{ + Name: req.Name, + Description: req.Description, + Version: req.Version, + CommandExecution: req.CommandExecution, + CreatedBy: user, + }) + if err != nil { + extensionsError(w, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("created extension %s (command execution %v)", e.Name, e.CommandExecution), utils.GetIP(r)) + view, err := h.extensionView(e.ID) + if err != nil { + extensionsError(w, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusCreated, view) +} + +// ExtensionsUpdateHandler - PATCH Handler to change an extension +// @Summary Update an osquery extension +// @Description Changes the fields that are set. Command execution can only change while the extension is not assigned, and cannot be switched off while a host reports it kept (osctrld could not remove it). Super admins only. +// @Tags extensions +// @Accept json +// @Produce json +// @Param id path int true "Extension ID" +// @Param request body types.ExtensionUpdateRequest true "Fields to change" +// @Success 200 {object} types.ExtensionView +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 409 {object} types.ApiErrorResponse "Extension is assigned, or still on a host that could not remove it" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/extensions/{id} [patch] +func (h *HandlersApi) ExtensionsUpdateHandler(w http.ResponseWriter, r *http.Request) { + user, ok := h.requireExtensionsAdmin(w, r) + if !ok { + return + } + id, err := parseAlertID(r, "id") + if err != nil { + apiErrorResponse(w, "invalid extension id", http.StatusBadRequest, err) + return + } + var req types.ExtensionUpdateRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, extensionJSONMaxBody)).Decode(&req); err != nil { + apiErrorResponse(w, "error parsing request body", http.StatusBadRequest, err) + return + } + cur, err := h.Extensions.Get(id) + if err != nil { + extensionsError(w, err) + return + } + // ponytail: read-then-write, so a concurrent PATCH of another field can be + // lost; add a column-level update in pkg/extensions if that matters. + desc, version := cur.Description, cur.Version + var fields []string + if req.Description != nil { + if len(*req.Description) > extensionDescriptionMax { + apiErrorResponse(w, fmt.Sprintf("description is longer than %d bytes", extensionDescriptionMax), http.StatusBadRequest, nil) + return + } + desc = *req.Description + fields = append(fields, "description") + } + if req.Version != nil { + version = *req.Version + fields = append(fields, "version") + } + if req.CommandExecution != nil { + fields = append(fields, "command_execution") + } + if len(fields) == 0 { + apiErrorResponse(w, "nothing to update", http.StatusBadRequest, nil) + return + } + e, err := h.Extensions.Update(id, desc, version, req.CommandExecution, user) + if err != nil { + extensionsError(w, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("updated extension %s: %s", e.Name, strings.Join(fields, ", ")), utils.GetIP(r)) + view, err := h.extensionView(e.ID) + if err != nil { + extensionsError(w, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, view) +} + +// ExtensionsDeleteHandler - DELETE Handler to remove an extension +// @Summary Delete an osquery extension +// @Description Removes an extension and its files. Refused while it is assigned to any environment or node, or, for a command-execution extension, while a host reports it kept (osctrld could not remove it). Super admins only. +// @Tags extensions +// @Produce json +// @Param id path int true "Extension ID" +// @Success 204 "No content" +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 409 {object} types.ApiErrorResponse "Extension is assigned, or still on a host that could not remove it" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/extensions/{id} [delete] +func (h *HandlersApi) ExtensionsDeleteHandler(w http.ResponseWriter, r *http.Request) { + user, ok := h.requireExtensionsAdmin(w, r) + if !ok { + return + } + id, err := parseAlertID(r, "id") + if err != nil { + apiErrorResponse(w, "invalid extension id", http.StatusBadRequest, err) + return + } + e, err := h.Extensions.Get(id) + if err != nil { + extensionsError(w, err) + return + } + if err := h.Extensions.Delete(id); err != nil { + extensionsError(w, err) + return + } + h.AuditLog.SettingsAction(user, "deleted extension "+e.Name, utils.GetIP(r)) + w.WriteHeader(http.StatusNoContent) +} + +// ExtensionFilePutHandler - PUT Handler to upload an extension binary +// @Summary Upload an osquery extension file +// @Description Stores or replaces the binary for one platform and arch from the multipart field `file` (at most 64 MiB). Platforms: darwin (amd64, arm64, universal), linux (amd64, arm64), windows (amd64). Super admins only. +// @Tags extensions +// @Accept multipart/form-data +// @Produce json +// @Param id path int true "Extension ID" +// @Param platform path string true "Platform" +// @Param arch path string true "Architecture" +// @Param file formData file true "Extension binary" +// @Success 200 {object} types.ExtensionFileView +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 413 {object} types.ApiErrorResponse "File too large" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/extensions/{id}/files/{platform}/{arch} [put] +func (h *HandlersApi) ExtensionFilePutHandler(w http.ResponseWriter, r *http.Request) { + user, ok := h.requireExtensionsAdmin(w, r) + if !ok { + return + } + id, err := parseAlertID(r, "id") + if err != nil { + apiErrorResponse(w, "invalid extension id", http.StatusBadRequest, err) + return + } + platform, arch := r.PathValue("platform"), r.PathValue("arch") + if !extensions.ValidPlatform(platform, arch) { + apiErrorResponse(w, "unsupported platform or arch", http.StatusBadRequest, nil) + return + } + e, err := h.Extensions.Get(id) + if err != nil { + extensionsError(w, err) + return + } + // PutFile holds a transaction while it reads, so a stalled client must + // not keep the connection forever. + if err := http.NewResponseController(w).SetReadDeadline(time.Now().Add(extensionUploadTimeout)); err != nil { + log.Debug().Err(err).Msg("extension upload: no read deadline") + } + r.Body = http.MaxBytesReader(w, r.Body, extensionUploadMaxBody) + // Stream the part straight into PutFile; ParseMultipartForm/FormFile + // would buffer the whole file first. + mr, err := r.MultipartReader() + if err != nil { + apiErrorResponse(w, "expected a multipart/form-data body", http.StatusBadRequest, err) + return + } + for { + part, err := mr.NextPart() + if errors.Is(err, io.EOF) { + apiErrorResponse(w, "missing multipart field file", http.StatusBadRequest, nil) + return + } + if err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + extensionsError(w, err) + } else { + apiErrorResponse(w, "invalid multipart body", http.StatusBadRequest, err) + } + return + } + if part.FormName() != "file" { + continue + } + f, err := h.Extensions.PutFile(r.Context(), id, platform, arch, sanitizeFilename(part.FileName()), part, user) + if err != nil { + extensionsError(w, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("uploaded %s/%s file for extension %s: sha256=%s size=%d", platform, arch, e.Name, f.SHA256, f.Size), utils.GetIP(r)) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toExtensionFileView(*f)) + return + } +} + +// ExtensionFileDeleteHandler - DELETE Handler to remove an extension binary +// @Summary Delete an osquery extension file +// @Description Removes the binary for one platform and arch. Super admins only. +// @Tags extensions +// @Produce json +// @Param id path int true "Extension ID" +// @Param platform path string true "Platform" +// @Param arch path string true "Architecture" +// @Success 204 "No content" +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/extensions/{id}/files/{platform}/{arch} [delete] +func (h *HandlersApi) ExtensionFileDeleteHandler(w http.ResponseWriter, r *http.Request) { + user, ok := h.requireExtensionsAdmin(w, r) + if !ok { + return + } + id, err := parseAlertID(r, "id") + if err != nil { + apiErrorResponse(w, "invalid extension id", http.StatusBadRequest, err) + return + } + platform, arch := r.PathValue("platform"), r.PathValue("arch") + if !extensions.ValidPlatform(platform, arch) { + apiErrorResponse(w, "unsupported platform or arch", http.StatusBadRequest, nil) + return + } + e, err := h.Extensions.Get(id) + if err != nil { + extensionsError(w, err) + return + } + if err := h.Extensions.DeleteFile(id, platform, arch); err != nil { + extensionsError(w, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("deleted %s/%s file for extension %s", platform, arch, e.Name), utils.GetIP(r)) + w.WriteHeader(http.StatusNoContent) +} diff --git a/cmd/api/handlers/extensions_assign.go b/cmd/api/handlers/extensions_assign.go new file mode 100644 index 000000000..e65747589 --- /dev/null +++ b/cmd/api/handlers/extensions_assign.go @@ -0,0 +1,423 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "time" + + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" +) + +// toExtensionSummary lists the platforms v has a file for; List returns the +// files ordered by platform. +func toExtensionSummary(v extensions.ExtensionView) types.ExtensionSummary { + platforms := []string{} + for _, f := range v.Files { + if len(platforms) == 0 || platforms[len(platforms)-1] != f.Platform { + platforms = append(platforms, f.Platform) + } + } + return types.ExtensionSummary{ + ID: v.ID, + Name: v.Name, + Description: v.Description, + Version: v.Version, + CommandExecution: v.CommandExecution, + Platforms: platforms, + } +} + +// envExtensions builds what the environment runs and the ordinary extensions +// that can be added to it. +func (h *HandlersApi) envExtensions(envID uint) (types.EnvironmentExtensionsResponse, error) { + resp := types.EnvironmentExtensionsResponse{Assigned: []types.ExtensionSummary{}, Available: []types.ExtensionSummary{}} + views, err := h.Extensions.List() + if err != nil { + return resp, err + } + assigned, err := h.Extensions.EnvExtensions(envID) + if err != nil { + return resp, err + } + isAssigned := make(map[uint]bool, len(assigned)) + for _, e := range assigned { + isAssigned[e.ID] = true + } + for _, v := range views { + switch { + case isAssigned[v.ID]: + resp.Assigned = append(resp.Assigned, toExtensionSummary(v)) + case !v.CommandExecution: + resp.Available = append(resp.Available, toExtensionSummary(v)) + } + } + return resp, nil +} + +// envExtensionTarget checks the caller is an admin of the environment and +// resolves the extension in the path. +func (h *HandlersApi) envExtensionTarget(w http.ResponseWriter, r *http.Request) (environments.TLSEnvironment, *extensions.Extension, string, bool) { + env, ctx, ok := h.inactivityEnvironment(w, r, users.AdminLevel) + if !ok { + return env, nil, "", false + } + id, err := parseAlertID(r, "id") + if err != nil { + apiErrorResponse(w, "invalid extension id", http.StatusBadRequest, err) + return env, nil, "", false + } + e, err := h.Extensions.Get(id) + if err != nil { + extensionsError(w, err) + return env, nil, "", false + } + return env, e, ctx[ctxUser], true +} + +func (h *HandlersApi) serveEnvExtensions(w http.ResponseWriter, envID uint) { + resp, err := h.envExtensions(envID) + if err != nil { + extensionsError(w, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// EnvExtensionsHandler - GET Handler for an environment's extensions +// @Summary Get environment extensions +// @Description Returns the extensions assigned to the environment and the ordinary extensions that can be assigned to it. Command-execution extensions are only assigned to nodes. Environment admins. +// @Tags environments +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Success 200 {object} types.EnvironmentExtensionsResponse +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/environments/extensions/{env} [get] +func (h *HandlersApi) EnvExtensionsHandler(w http.ResponseWriter, r *http.Request) { + env, _, ok := h.inactivityEnvironment(w, r, users.AdminLevel) + if !ok { + return + } + h.serveEnvExtensions(w, env.ID) +} + +// EnvExtensionAssignHandler - PUT Handler to assign an extension to an environment +// @Summary Assign extension to environment +// @Description Assigns an ordinary extension to every node of the environment. Command-execution extensions are refused with 409. Environment admins. +// @Tags environments +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Param id path int true "Extension ID" +// @Success 200 {object} types.EnvironmentExtensionsResponse +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 409 {object} types.ApiErrorResponse "Command-execution extension" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/environments/extensions/{env}/{id} [put] +func (h *HandlersApi) EnvExtensionAssignHandler(w http.ResponseWriter, r *http.Request) { + env, e, user, ok := h.envExtensionTarget(w, r) + if !ok { + return + } + if err := h.Extensions.AssignEnv(env.ID, e.ID, user); err != nil { + extensionsError(w, err) + return + } + h.AuditLog.EnvAction(user, fmt.Sprintf("assigned extension %s to environment %s", e.Name, env.Name), utils.GetIP(r), env.ID) + h.serveEnvExtensions(w, env.ID) +} + +// EnvExtensionRemoveHandler - DELETE Handler to remove an extension from an environment +// @Summary Remove extension from environment +// @Description Removes an extension from the environment. 404 when it is not assigned to the environment. Environment admins. +// @Tags environments +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Param id path int true "Extension ID" +// @Success 200 {object} types.EnvironmentExtensionsResponse +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/environments/extensions/{env}/{id} [delete] +func (h *HandlersApi) EnvExtensionRemoveHandler(w http.ResponseWriter, r *http.Request) { + env, e, user, ok := h.envExtensionTarget(w, r) + if !ok { + return + } + if err := h.Extensions.UnassignEnv(env.ID, e.ID); err != nil { + extensionsError(w, err) + return + } + h.AuditLog.EnvAction(user, fmt.Sprintf("removed extension %s from environment %s", e.Name, env.Name), utils.GetIP(r), env.ID) + h.serveEnvExtensions(w, env.ID) +} + +// nodeExtensions builds the node view. Like osctrl-tls, it only counts +// assignments and statuses recorded in the node's current environment. +func (h *HandlersApi) nodeExtensions(env environments.TLSEnvironment, node nodes.OsqueryNode, superAdmin bool) (types.NodeExtensionsResponse, error) { + resp := types.NodeExtensionsResponse{ + Effective: []types.NodeExtensionEffective{}, + Assignments: []types.NodeExtensionAssignment{}, + Status: []types.NodeExtensionStatusView{}, + } + views, err := h.Extensions.List() + if err != nil { + return resp, err + } + envExts, err := h.Extensions.EnvExtensions(env.ID) + if err != nil { + return resp, err + } + rows, err := h.Extensions.NodeAssignments(node.UUID) + if err != nil { + return resp, err + } + statuses, err := h.Extensions.NodeStatuses(node.UUID) + if err != nil { + return resp, err + } + byID := make(map[uint]types.ExtensionSummary, len(views)) + for _, v := range views { + byID[v.ID] = toExtensionSummary(v) + } + platform := nodes.NormalizePlatformBucket(strings.ToLower(node.Platform)) + effective := func(s types.ExtensionSummary, source string) { + resp.Effective = append(resp.Effective, types.NodeExtensionEffective{ + Name: s.Name, + Source: source, + CommandExecution: s.CommandExecution, + Platforms: s.Platforms, + Available: utils.Contains(s.Platforms, platform), + }) + } + for _, e := range envExts { + if s := byID[e.ID]; !s.CommandExecution { + effective(s, "environment") + } + } + now := time.Now() + // unexpired names the command-execution extensions this node should run + unexpired := map[string]bool{} + for _, a := range rows { + if a.EnvironmentID != env.ID { + continue + } + s := byID[a.ExtensionID] + if !a.Expired(now) { + unexpired[s.Name] = true + } + resp.Assignments = append(resp.Assignments, types.NodeExtensionAssignment{ + ExtensionID: a.ExtensionID, + Name: s.Name, + ExpiresAt: a.ExpiresAt, + Expired: a.Expired(now), + CreatedBy: a.CreatedBy, + CreatedAt: a.CreatedAt, + }) + if s.CommandExecution && !a.Expired(now) { + effective(s, "host") + } + } + sort.Slice(resp.Effective, func(i, j int) bool { return resp.Effective[i].Name < resp.Effective[j].Name }) + cmdExec := map[string]bool{} + for _, v := range views { + cmdExec[v.Name] = v.CommandExecution + } + for _, st := range statuses { + if st.EnvironmentID == env.ID { + resp.Status = append(resp.Status, types.NodeExtensionStatusView{ + Name: st.Name, SHA256: st.SHA256, Result: st.Result, Error: st.Error, At: st.At, ReportedAt: st.ReportedAt, + // osctrld reports a failed removal as kept; a later removed + // replaces it. Any other result is not evidence it is still there. + Lingering: cmdExec[st.Name] && st.Result == extensions.ResultKept && !unexpired[st.Name], + }) + } + } + if superAdmin { + resp.Library = []types.ExtensionSummary{} + for _, v := range views { + if v.CommandExecution { + resp.Library = append(resp.Library, byID[v.ID]) + } + } + } + return resp, nil +} + +func (h *HandlersApi) isSuperAdmin(r *http.Request, user string) bool { + return h.Users.CheckPermissionsContext(r.Context(), user, users.AdminLevel, users.NoEnvironment) +} + +func (h *HandlersApi) serveNodeExtensions(w http.ResponseWriter, env environments.TLSEnvironment, node nodes.OsqueryNode, superAdmin bool) { + resp, err := h.nodeExtensions(env, node, superAdmin) + if err != nil { + extensionsError(w, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) +} + +// nodeExtensionTarget resolves the node as an environment admin, then +// requires a super admin and resolves the extension in the path. +func (h *HandlersApi) nodeExtensionTarget(w http.ResponseWriter, r *http.Request) (environments.TLSEnvironment, nodes.OsqueryNode, *extensions.Extension, string, bool) { + env, node, user, ok := h.nodeConfigTarget(w, r) + if !ok { + return env, node, nil, "", false + } + if !h.isSuperAdmin(r, user) { + h.denyEnv(w, r, ContextValue{ctxUser: user}, env.ID, "command-execution extensions require a super admin") + return env, node, nil, "", false + } + id, err := parseAlertID(r, "id") + if err != nil { + apiErrorResponse(w, "invalid extension id", http.StatusBadRequest, err) + return env, node, nil, "", false + } + e, err := h.Extensions.Get(id) + if err != nil { + extensionsError(w, err) + return env, node, nil, "", false + } + return env, node, e, user, true +} + +// decodeNodeExtensionExpiry reads a body that is exactly one JSON object +// with the expires_at key. Missing expiry must not mean "never": a misspelled +// key would otherwise grant command execution without expiry, so only an +// explicit null does. +func decodeNodeExtensionExpiry(w http.ResponseWriter, r *http.Request) (*time.Time, error) { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, extensionJSONMaxBody)) + var body map[string]json.RawMessage + if err := dec.Decode(&body); err != nil { + return nil, err + } + if err := dec.Decode(new(any)); err != io.EOF { + return nil, fmt.Errorf("trailing data after the JSON object") + } + raw, ok := body["expires_at"] + if !ok || len(body) != 1 { + return nil, fmt.Errorf("want only the expires_at key") + } + var expiresAt *time.Time + if err := json.Unmarshal(raw, &expiresAt); err != nil { + return nil, err + } + return expiresAt, nil +} + +// NodeExtensionsHandler - GET Handler for a node's extensions +// @Summary Get node extensions +// @Description Returns the extensions the node should run, its command-execution assignments and the last status its osctrld reported. Status fields other than reported_at and lingering come from the host and are untrusted. lingering marks a command-execution extension the node has no unexpired assignment for that osctrld reports as kept, i.e. could not remove. library, the command-execution extensions that can be assigned, is null unless the caller is a super admin. +// @Tags nodes +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Param uuid path string true "Node UUID" +// @Success 200 {object} types.NodeExtensionsResponse +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/nodes/{env}/node/{uuid}/extensions [get] +func (h *HandlersApi) NodeExtensionsHandler(w http.ResponseWriter, r *http.Request) { + env, node, user, ok := h.nodeConfigTarget(w, r) + if !ok { + return + } + h.serveNodeExtensions(w, env, node, h.isSuperAdmin(r, user)) +} + +// NodeExtensionAssignHandler - PUT Handler to assign a command-execution extension to a node +// @Summary Assign command-execution extension to node +// @Description Allows command execution on the node through the extension until expires_at. The body must be exactly {"expires_at": ...}: a future RFC 3339 time, or an explicit null for no expiry. A missing expires_at, any other key or trailing data is refused with 400. Ordinary extensions are refused with 409. Super admins only. +// @Tags nodes +// @Accept json +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Param uuid path string true "Node UUID" +// @Param id path int true "Extension ID" +// @Param request body types.NodeExtensionAssignRequest true "Expiry" +// @Success 200 {object} types.NodeExtensionsResponse +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 409 {object} types.ApiErrorResponse "Not a command-execution extension" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/nodes/{env}/node/{uuid}/extensions/{id} [put] +func (h *HandlersApi) NodeExtensionAssignHandler(w http.ResponseWriter, r *http.Request) { + env, node, e, user, ok := h.nodeExtensionTarget(w, r) + if !ok { + return + } + expiresAt, err := decodeNodeExtensionExpiry(w, r) + if err != nil { + apiErrorResponse(w, `body must be exactly {"expires_at": }`, http.StatusBadRequest, err) + return + } + if expiresAt != nil && !expiresAt.After(time.Now()) { + apiErrorResponse(w, "expires_at must be in the future", http.StatusBadRequest, nil) + return + } + // pkg/extensions takes no context, so the write already outlives a + // cancelled request. + if _, err := h.Extensions.AssignNode(node.UUID, env.ID, e.ID, expiresAt, user); err != nil { + extensionsError(w, err) + return + } + expires := "never" + if expiresAt != nil { + expires = expiresAt.UTC().Format(time.RFC3339) + } + h.AuditLog.NodeAction(user, fmt.Sprintf("allowed command execution on node %s via extension %s (expires %s)", node.UUID, e.Name, expires), utils.GetIP(r), env.ID) + h.serveNodeExtensions(w, env, node, true) +} + +// NodeExtensionRemoveHandler - DELETE Handler to remove a command-execution extension from a node +// @Summary Remove command-execution extension from node +// @Description Removes the extension's assignment to the node. 404 when the node has no assignment of that extension. Super admins only. +// @Tags nodes +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Param uuid path string true "Node UUID" +// @Param id path int true "Extension ID" +// @Success 200 {object} types.NodeExtensionsResponse +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Failure 500 {object} types.ApiErrorResponse "Internal server error" +// @Security ApiKeyAuth +// @Router /api/v1/nodes/{env}/node/{uuid}/extensions/{id} [delete] +func (h *HandlersApi) NodeExtensionRemoveHandler(w http.ResponseWriter, r *http.Request) { + env, node, e, user, ok := h.nodeExtensionTarget(w, r) + if !ok { + return + } + if err := h.Extensions.UnassignNode(node.UUID, e.ID); err != nil { + extensionsError(w, err) + return + } + h.AuditLog.NodeAction(user, fmt.Sprintf("removed command execution extension %s from node %s", e.Name, node.UUID), utils.GetIP(r), env.ID) + h.serveNodeExtensions(w, env, node, true) +} diff --git a/cmd/api/handlers/extensions_assign_test.go b/cmd/api/handlers/extensions_assign_test.go new file mode 100644 index 000000000..a6f3b522f --- /dev/null +++ b/cmd/api/handlers/extensions_assign_test.go @@ -0,0 +1,432 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type assignFixture struct { + h *HandlersApi + db *gorm.DB + envA, envB environments.TLSEnvironment + mgr *extensions.Manager + // osq has a linux file, win a windows file, cmd is command execution + // with a linux file. + osq, win, cmd *extensions.Extension +} + +// setupAssignHandlers: alice is admin of env a, root a super admin, bob +// nothing. NODE-A (ubuntu) is in a, NODE-B in b. +func setupAssignHandlers(t *testing.T) assignFixture { + t.Helper() + h, db, envA, envB, _ := setupNodeConfigHandlers(t) + mgr, err := extensions.NewManager(db) + require.NoError(t, err) + WithExtensions(mgr)(h) + require.NoError(t, db.Model(&nodes.OsqueryNode{}).Where("uuid = ?", "NODE-A").Update("platform", "ubuntu").Error) + mk := func(name, platform, arch string, cmdExec bool) *extensions.Extension { + e, err := mgr.Create(extensions.Extension{Name: name, CommandExecution: cmdExec, CreatedBy: "root"}) + require.NoError(t, err) + _, err = mgr.PutFile(context.Background(), e.ID, platform, arch, name, strings.NewReader(name), "root") + require.NoError(t, err) + return e + } + return assignFixture{ + h: h, db: db, envA: envA, envB: envB, mgr: mgr, + osq: mk("osq", "linux", "amd64", false), + win: mk("win", "windows", "amd64", false), + cmd: mk("cmd", "linux", "arm64", true), + } +} + +func callEnvExts(env string) extCall { + return extCall{http.MethodGet, "/api/v1/environments/extensions/" + env, + func(h *HandlersApi) http.HandlerFunc { return h.EnvExtensionsHandler }, map[string]string{"env": env}} +} + +func callEnvExt(method, env string, id uint) extCall { + hf := func(h *HandlersApi) http.HandlerFunc { return h.EnvExtensionAssignHandler } + if method == http.MethodDelete { + hf = func(h *HandlersApi) http.HandlerFunc { return h.EnvExtensionRemoveHandler } + } + return extCall{method, fmt.Sprintf("/api/v1/environments/extensions/%s/%d", env, id), hf, + map[string]string{"env": env, "id": fmt.Sprint(id)}} +} + +func callNodeExts(env, uuid string) extCall { + return extCall{http.MethodGet, "/api/v1/nodes/" + env + "/node/" + uuid + "/extensions", + func(h *HandlersApi) http.HandlerFunc { return h.NodeExtensionsHandler }, map[string]string{"env": env, "uuid": uuid}} +} + +func callNodeExt(method, env, uuid string, id uint) extCall { + hf := func(h *HandlersApi) http.HandlerFunc { return h.NodeExtensionAssignHandler } + if method == http.MethodDelete { + hf = func(h *HandlersApi) http.HandlerFunc { return h.NodeExtensionRemoveHandler } + } + return extCall{method, fmt.Sprintf("/api/v1/nodes/%s/node/%s/extensions/%d", env, uuid, id), hf, + map[string]string{"env": env, "uuid": uuid, "id": fmt.Sprint(id)}} +} + +func names(s []types.ExtensionSummary) []string { + out := []string{} + for _, e := range s { + out = append(out, e.Name) + } + return out +} + +func envExts(t *testing.T, f assignFixture, user string) types.EnvironmentExtensionsResponse { + t.Helper() + rr := extReq(t, f.h, callEnvExts(f.envA.Name), user, nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var out types.EnvironmentExtensionsResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &out)) + return out +} + +func nodeExts(t *testing.T, f assignFixture, user string) types.NodeExtensionsResponse { + t.Helper() + rr := extReq(t, f.h, callNodeExts(f.envA.Name, "NODE-A"), user, nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var out types.NodeExtensionsResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &out)) + return out +} + +func expiresBody(t *testing.T, at *time.Time) *bytes.Reader { + t.Helper() + raw, err := json.Marshal(types.NodeExtensionAssignRequest{ExpiresAt: at}) + require.NoError(t, err) + return bytes.NewReader(raw) +} + +func TestEnvExtensionsAssign(t *testing.T) { + f := setupAssignHandlers(t) + + got := envExts(t, f, "alice") + require.Empty(t, got.Assigned) + require.Equal(t, []string{"osq", "win"}, names(got.Available), "command-execution extensions are never available to environments") + require.Equal(t, []string{"linux"}, got.Available[0].Platforms) + + rr := extReq(t, f.h, callEnvExt(http.MethodPut, f.envA.Name, f.osq.ID), "alice", nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + got = envExts(t, f, "alice") + require.Equal(t, []string{"osq"}, names(got.Assigned)) + require.Equal(t, []string{"win"}, names(got.Available)) + + rr = extReq(t, f.h, callEnvExt(http.MethodPut, f.envA.Name, f.cmd.ID), "alice", nil, "") + require.Equal(t, http.StatusConflict, rr.Code, rr.Body.String()) + rr = extReq(t, f.h, callEnvExt(http.MethodPut, f.envA.Name, 999), "alice", nil, "") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + + rr = extReq(t, f.h, callEnvExt(http.MethodDelete, f.envA.Name, f.osq.ID), "alice", nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + require.Empty(t, envExts(t, f, "alice").Assigned) + // Removing an extension that is not assigned removes and audits nothing. + for _, id := range []uint{f.osq.ID, f.win.ID} { + rr = extReq(t, f.h, callEnvExt(http.MethodDelete, f.envA.Name, id), "alice", nil, "") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + } + + lines := auditLines(t, f.db, auditlog.LogTypeEnvironment, f.envA.ID) + require.Len(t, lines, 2) + require.Equal(t, "user alice performed environment action: assigned extension osq to environment a", lines[0]) + require.Equal(t, "user alice performed environment action: removed extension osq from environment a", lines[1]) +} + +func TestEnvExtensionsPermissions(t *testing.T) { + f := setupAssignHandlers(t) + for _, c := range []struct { + user string + env environments.TLSEnvironment + }{{"bob", f.envA}, {"alice", f.envB}} { + for _, call := range []extCall{ + callEnvExts(c.env.Name), + callEnvExt(http.MethodPut, c.env.Name, f.osq.ID), + callEnvExt(http.MethodDelete, c.env.Name, f.osq.ID), + } { + before := len(auditLines(t, f.db, auditlog.LogTypeEnvironment, c.env.ID)) + rr := extReq(t, f.h, call, c.user, nil, "") + require.Equal(t, http.StatusForbidden, rr.Code, "%s %s %s", c.user, call.method, call.path) + lines := auditLines(t, f.db, auditlog.LogTypeEnvironment, c.env.ID) + require.Len(t, lines, before+1, "%s %s %s", c.user, call.method, call.path) + require.Contains(t, lines[len(lines)-1], "denied access for user "+c.user) + } + } + for _, env := range []uint{f.envA.ID, f.envB.ID} { + assigned, err := f.mgr.EnvExtensions(env) + require.NoError(t, err) + require.Empty(t, assigned) + } +} + +func TestNodeExtensionsView(t *testing.T) { + f := setupAssignHandlers(t) + require.NoError(t, f.mgr.AssignEnv(f.envA.ID, f.osq.ID, "alice")) + require.NoError(t, f.mgr.AssignEnv(f.envA.ID, f.win.ID, "alice")) + _, err := f.mgr.AssignNode("NODE-A", f.envA.ID, f.cmd.ID, nil, "root") + require.NoError(t, err) + at := time.Now().UTC().Truncate(time.Second) + sha := strings.Repeat("a", 64) + require.NoError(t, f.mgr.RecordStatus("NODE-A", f.envA.ID, []types.OsctrldExtensionStatus{ + {Name: "osq", SHA256: sha, Result: "rejected", Error: "bad", At: at}, + }, at)) + // Not served in env a, so not reported there. + require.NoError(t, f.mgr.RecordStatus("NODE-A", f.envB.ID, []types.OsctrldExtensionStatus{ + {Name: "win", SHA256: sha, Result: "ok", At: at}, + }, at)) + + got := nodeExts(t, f, "alice") + require.Nil(t, got.Library, "only super admins see the command-execution library") + require.Equal(t, []types.NodeExtensionEffective{ + {Name: "cmd", Source: "host", CommandExecution: true, Platforms: []string{"linux"}, Available: true}, + {Name: "osq", Source: "environment", Platforms: []string{"linux"}, Available: true}, + {Name: "win", Source: "environment", Platforms: []string{"windows"}, Available: false}, + }, got.Effective) + require.Len(t, got.Assignments, 1) + require.Equal(t, f.cmd.ID, got.Assignments[0].ExtensionID) + require.Equal(t, "cmd", got.Assignments[0].Name) + require.Equal(t, "root", got.Assignments[0].CreatedBy) + require.Nil(t, got.Assignments[0].ExpiresAt) + require.False(t, got.Assignments[0].Expired) + require.Len(t, got.Status, 1) + require.Equal(t, "osq", got.Status[0].Name) + require.Equal(t, sha, got.Status[0].SHA256) + require.Equal(t, "rejected", got.Status[0].Result) + require.Equal(t, "bad", got.Status[0].Error) + require.True(t, at.Equal(*got.Status[0].At)) + + root := nodeExts(t, f, "root") + require.Equal(t, []string{"cmd"}, names(root.Library)) + + // An expired assignment is listed but not effective. + past := time.Now().Add(-time.Hour) + _, err = f.mgr.AssignNode("NODE-A", f.envA.ID, f.cmd.ID, &past, "root") + require.NoError(t, err) + got = nodeExts(t, f, "alice") + require.True(t, got.Assignments[0].Expired) + require.Equal(t, []string{"osq", "win"}, []string{got.Effective[0].Name, got.Effective[1].Name}) + + // Cross-environment node and environment. + rr := extReq(t, f.h, callNodeExts(f.envA.Name, "NODE-B"), "alice", nil, "") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + rr = extReq(t, f.h, callNodeExts(f.envB.Name, "NODE-B"), "alice", nil, "") + require.Equal(t, http.StatusForbidden, rr.Code, rr.Body.String()) +} + +func TestNodeExtensionAssign(t *testing.T) { + f := setupAssignHandlers(t) + future := time.Now().Add(time.Hour).UTC().Truncate(time.Second) + + // Environment admins and others are refused, and audited. + for _, user := range []string{"alice", "bob"} { + for _, call := range []extCall{ + callNodeExt(http.MethodPut, f.envA.Name, "NODE-A", f.cmd.ID), + callNodeExt(http.MethodDelete, f.envA.Name, "NODE-A", f.cmd.ID), + } { + before := len(auditLines(t, f.db, auditlog.LogTypeEnvironment, f.envA.ID)) + rr := extReq(t, f.h, call, user, expiresBody(t, &future), "application/json") + require.Equal(t, http.StatusForbidden, rr.Code, "%s %s", user, call.method) + lines := auditLines(t, f.db, auditlog.LogTypeEnvironment, f.envA.ID) + require.Len(t, lines, before+1, "%s %s", user, call.method) + require.Contains(t, lines[len(lines)-1], "denied access for user "+user) + } + } + rows, err := f.mgr.NodeAssignments("NODE-A") + require.NoError(t, err) + require.Empty(t, rows) + + put := callNodeExt(http.MethodPut, f.envA.Name, "node-a", f.cmd.ID) + past := time.Now().Add(-time.Minute) + rr := extReq(t, f.h, put, "root", expiresBody(t, &past), "application/json") + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + rr = extReq(t, f.h, put, "root", strings.NewReader("not json"), "application/json") + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + rr = extReq(t, f.h, callNodeExt(http.MethodPut, f.envA.Name, "NODE-A", f.osq.ID), "root", expiresBody(t, nil), "application/json") + require.Equal(t, http.StatusConflict, rr.Code, rr.Body.String()) + rr = extReq(t, f.h, callNodeExt(http.MethodPut, f.envA.Name, "NODE-A", 999), "root", expiresBody(t, nil), "application/json") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + rr = extReq(t, f.h, callNodeExt(http.MethodPut, f.envA.Name, "NODE-B", f.cmd.ID), "root", expiresBody(t, nil), "application/json") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + + // The path UUID is lower case; the assignment uses the node row's. + rr = extReq(t, f.h, put, "root", expiresBody(t, &future), "application/json") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var got types.NodeExtensionsResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) + require.Len(t, got.Assignments, 1) + require.True(t, future.Equal(*got.Assignments[0].ExpiresAt)) + rows, err = f.mgr.NodeAssignments("NODE-A") + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, f.envA.ID, rows[0].EnvironmentID) + + // The expiry must be given explicitly: a missing, misspelled or extra key, + // or trailing data, must not grant command execution without expiry. + for _, body := range []string{ + `{}`, + `{"expiresAt":"` + future.Format(time.RFC3339) + `"}`, + `{"expires":null}`, + `{"expires_at":null,"extra":1}`, + `{"expires_at":null} {}`, + `{"expires_at":null}x`, + ``, + } { + rr = extReq(t, f.h, put, "root", strings.NewReader(body), "application/json") + require.Equal(t, http.StatusBadRequest, rr.Code, "%s: %s", body, rr.Body.String()) + rows, err = f.mgr.NodeAssignments("NODE-A") + require.NoError(t, err) + require.True(t, future.Equal(*rows[0].ExpiresAt), "%s must not change the assignment", body) + } + + rr = extReq(t, f.h, put, "root", strings.NewReader(`{"expires_at":null}`), "application/json") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + rows, err = f.mgr.NodeAssignments("NODE-A") + require.NoError(t, err) + require.Nil(t, rows[0].ExpiresAt) + + rr = extReq(t, f.h, callNodeExt(http.MethodDelete, f.envA.Name, "node-a", f.cmd.ID), "root", nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + rows, err = f.mgr.NodeAssignments("NODE-A") + require.NoError(t, err) + require.Empty(t, rows) + // Nothing assigned: an ordinary extension's ID, or a repeat removal, is + // 404 and not audited. + for _, id := range []uint{f.osq.ID, f.cmd.ID} { + rr = extReq(t, f.h, callNodeExt(http.MethodDelete, f.envA.Name, "NODE-A", id), "root", nil, "") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + } + + lines := auditLines(t, f.db, auditlog.LogTypeNode, f.envA.ID) + require.Equal(t, []string{ + "user root performed node action: allowed command execution on node NODE-A via extension cmd (expires " + future.Format(time.RFC3339) + ")", + "user root performed node action: allowed command execution on node NODE-A via extension cmd (expires never)", + "user root performed node action: removed command execution extension cmd from node NODE-A", + }, lines) +} + +func TestDeleteNodeRemovesExtensions(t *testing.T) { + deleteNode := func(t *testing.T, h *HandlersApi) { + body, err := json.Marshal(types.ApiNodeGenericRequest{UUID: "node-a"}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/nodes/a/delete", bytes.NewReader(body)) + req.RemoteAddr = "127.0.0.1:1234" + req.SetPathValue("env", "a") + req = req.WithContext(context.WithValue(req.Context(), ContextKey(contextAPI), ContextValue{ctxUser: "alice"})) + rr := httptest.NewRecorder() + h.DeleteNodeHandler(rr, req) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + } + t.Run("removes rows", func(t *testing.T) { + f := setupAssignHandlers(t) + _, err := f.mgr.AssignNode("NODE-A", f.envA.ID, f.cmd.ID, nil, "root") + require.NoError(t, err) + require.NoError(t, f.mgr.RecordStatus("NODE-A", f.envA.ID, []types.OsctrldExtensionStatus{ + {Name: "cmd", SHA256: strings.Repeat("a", 64), Result: "ok", At: time.Now()}, + }, time.Now())) + deleteNode(t, f.h) + rows, err := f.mgr.NodeAssignments("NODE-A") + require.NoError(t, err) + require.Empty(t, rows) + st, err := f.mgr.NodeStatuses("NODE-A") + require.NoError(t, err) + require.Empty(t, st) + }) + t.Run("nil manager", func(t *testing.T) { + f := setupAssignHandlers(t) + f.h.Extensions = nil + deleteNode(t, f.h) + }) +} + +// A command-execution extension osctrld reports as kept while the node has +// no unexpired assignment for it is lingering, for environment and super +// admins alike. A stale ok sent after the removal is not. +func TestNodeExtensionsLingering(t *testing.T) { + f := setupAssignHandlers(t) + require.NoError(t, f.mgr.AssignEnv(f.envA.ID, f.osq.ID, "alice")) + _, err := f.mgr.AssignNode("NODE-A", f.envA.ID, f.cmd.ID, nil, "root") + require.NoError(t, err) + at := time.Now().UTC().Truncate(time.Second) + report := func(name, result, msg string) { + require.NoError(t, f.mgr.RecordStatus("NODE-A", f.envA.ID, []types.OsctrldExtensionStatus{ + {Name: name, SHA256: strings.Repeat("a", 64), Result: result, Error: msg, At: at}, + }, at)) + } + byName := func(got types.NodeExtensionsResponse) map[string]types.NodeExtensionStatusView { + out := map[string]types.NodeExtensionStatusView{} + for _, s := range got.Status { + out[s.Name] = s + } + return out + } + lingering := func(user string) bool { + return byName(nodeExts(t, f, user))["cmd"].Lingering + } + report("cmd", "ok", "") + report("osq", "ok", "") + require.False(t, lingering("alice"), "assigned") + + rr := extReq(t, f.h, callNodeExt(http.MethodDelete, f.envA.Name, "NODE-A", f.cmd.ID), "root", nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + _, ok := byName(nodeExts(t, f, "alice"))["cmd"] + require.False(t, ok, "the removed assignment's status went with it") + + // osctrld queued ok before the removal and sends it on the next request + report("cmd", "ok", "") + require.False(t, lingering("alice"), "a stale ok after unassigning") + + report("cmd", "kept", "osqueryd was not running 60s after the extension was removed") + report("osq", "kept", "x") + for _, user := range []string{"alice", "root"} { + got := byName(nodeExts(t, f, user)) + require.True(t, got["cmd"].Lingering, user) + require.Equal(t, "kept", got["cmd"].Result, user) + require.False(t, got["osq"].Lingering, "ordinary extensions never linger") + } + + // The evidence cannot be erased by deleting the extension + rr = extReq(t, f.h, callDelete(f.cmd.ID), "root", nil, "") + require.Equal(t, http.StatusConflict, rr.Code, rr.Body.String()) + + report("cmd", "removed", "") + require.False(t, lingering("alice"), "removed after kept") + + // Kept with an expired assignment: still on the host, so lingering + past := time.Now().Add(-time.Hour) + _, err = f.mgr.AssignNode("NODE-A", f.envA.ID, f.cmd.ID, &past, "root") + require.NoError(t, err) + report("cmd", "kept", "cannot move") + require.True(t, lingering("alice"), "kept with an expired assignment") + + // Removing the expired assignment keeps the evidence + rr = extReq(t, f.h, callNodeExt(http.MethodDelete, f.envA.Name, "NODE-A", f.cmd.ID), "root", nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + require.True(t, lingering("alice"), "kept survives removing the expired assignment") + + // Nor can it be hidden by turning command execution off + rr = extReq(t, f.h, callUpdate(f.cmd.ID), "root", strings.NewReader(`{"command_execution":false}`), "application/json") + require.Equal(t, http.StatusConflict, rr.Code, rr.Body.String()) + require.True(t, lingering("alice")) + + // Kept with an unexpired assignment: it is meant to be there + future := time.Now().Add(time.Hour) + _, err = f.mgr.AssignNode("NODE-A", f.envA.ID, f.cmd.ID, &future, "root") + require.NoError(t, err) + require.False(t, lingering("alice"), "kept with an unexpired assignment") +} diff --git a/cmd/api/handlers/extensions_test.go b/cmd/api/handlers/extensions_test.go new file mode 100644 index 000000000..e8070f94e --- /dev/null +++ b/cmd/api/handlers/extensions_test.go @@ -0,0 +1,361 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/config" + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupExtensionsHandlers(t *testing.T) (*HandlersApi, *gorm.DB, environments.TLSEnvironment) { + t.Helper() + dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{TranslateError: true}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + envs := environments.CreateEnvironment(db) + userManager := users.CreateUserManager(db) + auditLog, err := auditlog.CreateAuditLogManager(db, "osctrl-api", true) + require.NoError(t, err) + mgr, err := extensions.NewManager(db) + require.NoError(t, err) + + envA := environments.TLSEnvironment{UUID: "env-a", Name: "a", Configuration: "{}"} + require.NoError(t, db.Create(&envA).Error) + require.NoError(t, userManager.Create(users.AdminUser{Username: "alice"})) + require.NoError(t, userManager.Create(users.AdminUser{Username: "bob"})) + require.NoError(t, userManager.Create(users.AdminUser{Username: "root", Admin: true})) + require.NoError(t, userManager.CreatePermission(users.UserPermission{ + Username: "alice", AccessType: int(users.AdminLevel), AccessValue: true, Environment: envA.UUID, EnvironmentID: envA.ID, + })) + + h := CreateHandlersApi( + WithDB(db), WithEnvs(envs), WithUsers(userManager), WithAuditLog(auditLog), + WithExtensions(mgr), WithDebugHTTP(&config.YAMLConfigurationDebug{}), + ) + return h, db, envA +} + +type extCall struct { + method, path string + handler func(*HandlersApi) http.HandlerFunc + vals map[string]string +} + +func extReq(t *testing.T, h *HandlersApi, c extCall, user string, body io.Reader, contentType string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(c.method, c.path, body) + req.RemoteAddr = "127.0.0.1:1234" + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + for k, v := range c.vals { + req.SetPathValue(k, v) + } + req = req.WithContext(context.WithValue(req.Context(), ContextKey(contextAPI), ContextValue{ctxUser: user})) + rr := httptest.NewRecorder() + c.handler(h)(rr, req) + return rr +} + +func callList() extCall { + return extCall{http.MethodGet, "/api/v1/extensions", func(h *HandlersApi) http.HandlerFunc { return h.ExtensionsListHandler }, nil} +} + +func callCreate() extCall { + return extCall{http.MethodPost, "/api/v1/extensions", func(h *HandlersApi) http.HandlerFunc { return h.ExtensionsCreateHandler }, nil} +} + +func callUpdate(id uint) extCall { + return extCall{http.MethodPatch, fmt.Sprintf("/api/v1/extensions/%d", id), + func(h *HandlersApi) http.HandlerFunc { return h.ExtensionsUpdateHandler }, map[string]string{"id": fmt.Sprint(id)}} +} + +func callDelete(id uint) extCall { + return extCall{http.MethodDelete, fmt.Sprintf("/api/v1/extensions/%d", id), + func(h *HandlersApi) http.HandlerFunc { return h.ExtensionsDeleteHandler }, map[string]string{"id": fmt.Sprint(id)}} +} + +func callFile(method string, id uint, platform, arch string) extCall { + hf := func(h *HandlersApi) http.HandlerFunc { return h.ExtensionFilePutHandler } + if method == http.MethodDelete { + hf = func(h *HandlersApi) http.HandlerFunc { return h.ExtensionFileDeleteHandler } + } + return extCall{method, fmt.Sprintf("/api/v1/extensions/%d/files/%s/%s", id, platform, arch), hf, + map[string]string{"id": fmt.Sprint(id), "platform": platform, "arch": arch}} +} + +func createExt(t *testing.T, h *HandlersApi, name string, cmdExec bool) types.ExtensionView { + t.Helper() + body := fmt.Sprintf(`{"name":%q,"description":"d","version":"1.0","command_execution":%v}`, name, cmdExec) + rr := extReq(t, h, callCreate(), "root", strings.NewReader(body), "application/json") + require.Equal(t, http.StatusCreated, rr.Code, rr.Body.String()) + var v types.ExtensionView + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &v)) + return v +} + +// content returns n deterministic bytes for seed. +func content(n, seed int64) io.Reader { + return io.LimitReader(rand.New(rand.NewSource(seed)), n) +} + +func contentSHA(t *testing.T, n, seed int64) string { + t.Helper() + hs := sha256.New() + _, err := io.Copy(hs, content(n, seed)) + require.NoError(t, err) + return hex.EncodeToString(hs.Sum(nil)) +} + +// multipartBody streams a multipart body with field holding data, so the +// test never holds the whole upload in memory either. +func multipartBody(t *testing.T, field, filename string, data io.Reader) (io.Reader, string) { + t.Helper() + pr, pw := io.Pipe() + mw := multipart.NewWriter(pw) + go func() { + part, err := mw.CreateFormFile(field, filename) + if err == nil { + _, err = io.Copy(part, data) + } + if err == nil { + err = mw.Close() + } + pw.CloseWithError(err) + }() + t.Cleanup(func() { _ = pr.Close() }) + return pr, mw.FormDataContentType() +} + +func upload(t *testing.T, h *HandlersApi, id uint, platform, arch string, n, seed int64) *httptest.ResponseRecorder { + t.Helper() + body, ct := multipartBody(t, "file", "ext.ext", content(n, seed)) + return extReq(t, h, callFile(http.MethodPut, id, platform, arch), "root", body, ct) +} + +func listExts(t *testing.T, h *HandlersApi) []types.ExtensionView { + t.Helper() + rr := extReq(t, h, callList(), "root", nil, "") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var out []types.ExtensionView + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &out)) + return out +} + +func settingLines(t *testing.T, db *gorm.DB) []string { + t.Helper() + return auditLines(t, db, auditlog.LogTypeSetting, auditlog.NoEnvironment) +} + +func TestExtensionsPermissions(t *testing.T) { + h, db, _ := setupExtensionsHandlers(t) + ext := createExt(t, h, "ext1", false) + + calls := []struct { + c extCall + body string + }{ + {callList(), ""}, + {callCreate(), `{"name":"ext2"}`}, + {callUpdate(ext.ID), `{"description":"x"}`}, + {callDelete(ext.ID), ""}, + {callFile(http.MethodPut, ext.ID, "linux", "amd64"), ""}, + {callFile(http.MethodDelete, ext.ID, "linux", "amd64"), ""}, + } + for _, user := range []string{"alice", "bob"} { + for _, c := range calls { + before := len(settingLines(t, db)) + rr := extReq(t, h, c.c, user, strings.NewReader(c.body), "application/json") + require.Equal(t, http.StatusForbidden, rr.Code, "%s %s %s", user, c.c.method, c.c.path) + lines := settingLines(t, db) + require.Len(t, lines, before+1, "%s %s %s", user, c.c.method, c.c.path) + require.Contains(t, lines[len(lines)-1], "denied access for user "+user) + } + } + got := listExts(t, h) + require.Len(t, got, 1, "denied requests must not change the library") + require.Equal(t, "d", got[0].Description) +} + +func TestExtensionsCRUD(t *testing.T) { + h, db, envA := setupExtensionsHandlers(t) + ext := createExt(t, h, "ext1", false) + require.Equal(t, "ext1", ext.Name) + require.Equal(t, "root", ext.CreatedBy) + require.NotNil(t, ext.Files) + + // Invalid name, duplicate, oversize description. + for body, code := range map[string]int{ + `{"name":"bad name"}`: http.StatusBadRequest, + `{"name":"ext1"}`: http.StatusConflict, + `{"name":"EXT1"}`: http.StatusConflict, + `not json`: http.StatusBadRequest, + `{"name":"ext3","description":"` + strings.Repeat("x", 1025) + `"}`: http.StatusBadRequest, + } { + rr := extReq(t, h, callCreate(), "root", strings.NewReader(body), "application/json") + require.Equal(t, code, rr.Code, "%.40s: %s", body, rr.Body.String()) + } + + // PATCH only changes the fields that are set. + rr := extReq(t, h, callUpdate(ext.ID), "root", strings.NewReader(`{"version":"2.0"}`), "application/json") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var v types.ExtensionView + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &v)) + require.Equal(t, "2.0", v.Version) + require.Equal(t, "d", v.Description) + require.False(t, v.CommandExecution) + + rr = extReq(t, h, callUpdate(999), "root", strings.NewReader(`{"version":"2.0"}`), "application/json") + require.Equal(t, http.StatusNotFound, rr.Code) + + // Assigned: flipping command execution and deleting are refused. + require.NoError(t, h.Extensions.AssignEnv(envA.ID, ext.ID, "root")) + got := listExts(t, h) + require.Len(t, got, 1) + require.Equal(t, 1, got[0].Environments) + require.Equal(t, 0, got[0].Nodes) + rr = extReq(t, h, callUpdate(ext.ID), "root", strings.NewReader(`{"command_execution":true}`), "application/json") + require.Equal(t, http.StatusConflict, rr.Code, rr.Body.String()) + rr = extReq(t, h, callDelete(ext.ID), "root", nil, "") + require.Equal(t, http.StatusConflict, rr.Code, rr.Body.String()) + + require.NoError(t, h.Extensions.UnassignEnv(envA.ID, ext.ID)) + rr = extReq(t, h, callUpdate(ext.ID), "root", strings.NewReader(`{"command_execution":true,"description":"new"}`), "application/json") + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + rr = extReq(t, h, callDelete(ext.ID), "root", nil, "") + require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) + require.Empty(t, listExts(t, h)) + rr = extReq(t, h, callDelete(ext.ID), "root", nil, "") + require.Equal(t, http.StatusNotFound, rr.Code) + + lines := strings.Join(settingLines(t, db), "\n") + require.Contains(t, lines, "created extension ext1 (command execution false)") + require.Contains(t, lines, "updated extension ext1: version") + require.Contains(t, lines, "updated extension ext1: description, command_execution") + require.Contains(t, lines, "deleted extension ext1") +} + +func TestExtensionsUpload(t *testing.T) { + h, db, _ := setupExtensionsHandlers(t) + ext := createExt(t, h, "ext1", false) + + rr := upload(t, h, ext.ID, "linux", "amd64", 1<<20, 1) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + var f types.ExtensionFileView + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &f)) + sha1MiB := contentSHA(t, 1<<20, 1) + require.Equal(t, sha1MiB, f.SHA256) + require.Equal(t, int64(1<<20), f.Size) + require.Equal(t, "ext.ext", f.Filename) + require.Equal(t, "root", f.UpdatedBy) + + // Replace changes the SHA-256. + rr = upload(t, h, ext.ID, "linux", "amd64", 1<<20, 2) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + got := listExts(t, h) + require.Len(t, got[0].Files, 1) + require.Equal(t, contentSHA(t, 1<<20, 2), got[0].Files[0].SHA256) + + // Exactly 64 MiB is accepted. + rr = upload(t, h, ext.ID, "linux", "arm64", extensions.MaxFileSize, 3) + require.Equal(t, http.StatusOK, rr.Code, rr.Body.String()) + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &f)) + require.Equal(t, int64(extensions.MaxFileSize), f.Size) + require.Equal(t, contentSHA(t, extensions.MaxFileSize, 3), f.SHA256) + + var chunksBefore int64 + require.NoError(t, db.Model(&extensions.ExtensionChunk{}).Count(&chunksBefore).Error) + + // 64 MiB + 1 is refused and stores nothing. + rr = upload(t, h, ext.ID, "darwin", "universal", extensions.MaxFileSize+1, 4) + require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code, rr.Body.String()) + // A body over the cap before the file part is stopped by the body limit. + pr, pw := io.Pipe() + t.Cleanup(func() { _ = pr.Close() }) + mw := multipart.NewWriter(pw) + go func() { + pad, _ := mw.CreateFormField("pad") + _, err := io.Copy(pad, content(extensions.MaxFileSize+2<<20, 4)) + pw.CloseWithError(err) + }() + rr = extReq(t, h, callFile(http.MethodPut, ext.ID, "darwin", "universal"), "root", pr, mw.FormDataContentType()) + require.Equal(t, http.StatusRequestEntityTooLarge, rr.Code, rr.Body.String()) + var chunksAfter int64 + require.NoError(t, db.Model(&extensions.ExtensionChunk{}).Count(&chunksAfter).Error) + require.Equal(t, chunksBefore, chunksAfter) + require.Len(t, listExts(t, h)[0].Files, 2) + + // Empty file, bad platform or arch, missing file part, unknown extension. + rr = upload(t, h, ext.ID, "windows", "amd64", 0, 5) + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + rr = upload(t, h, ext.ID, "freebsd", "amd64", 10, 5) + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + rr = upload(t, h, ext.ID, "windows", "arm64", 10, 5) + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + body, ct := multipartBody(t, "other", "x", content(10, 5)) + rr = extReq(t, h, callFile(http.MethodPut, ext.ID, "windows", "amd64"), "root", body, ct) + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + rr = extReq(t, h, callFile(http.MethodPut, ext.ID, "windows", "amd64"), "root", strings.NewReader("{}"), "application/json") + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + rr = upload(t, h, 999, "windows", "amd64", 10, 5) + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + + // A truncated body (client gone) stores nothing. + pr, pw = io.Pipe() + mw = multipart.NewWriter(pw) + go func() { + part, _ := mw.CreateFormFile("file", "x") + _, _ = io.Copy(part, content(10, 6)) + _ = pw.Close() // no closing boundary + }() + rr = extReq(t, h, callFile(http.MethodPut, ext.ID, "windows", "amd64"), "root", pr, mw.FormDataContentType()) + require.Equal(t, http.StatusBadRequest, rr.Code, rr.Body.String()) + require.Contains(t, rr.Body.String(), "cut short") + require.Len(t, listExts(t, h)[0].Files, 2) + + // Delete a file. + rr = extReq(t, h, callFile(http.MethodDelete, ext.ID, "linux", "arm64"), "root", nil, "") + require.Equal(t, http.StatusNoContent, rr.Code, rr.Body.String()) + rr = extReq(t, h, callFile(http.MethodDelete, ext.ID, "linux", "arm64"), "root", nil, "") + require.Equal(t, http.StatusNotFound, rr.Code, rr.Body.String()) + require.Len(t, listExts(t, h)[0].Files, 1) + + lines := strings.Join(settingLines(t, db), "\n") + require.Contains(t, lines, fmt.Sprintf("uploaded linux/amd64 file for extension ext1: sha256=%s size=%d", sha1MiB, 1<<20)) + require.Contains(t, lines, "deleted linux/arm64 file for extension ext1") +} + +func TestSanitizeExtensionFilename(t *testing.T) { + for in, want := range map[string]string{ + "ext.ext": "ext.ext", + `C:\Users\x\ext.exe`: "ext.exe", + "a/b/../c.ext": "c.ext", + "evil\r\nname\x00.ext": "evilname.ext", + strings.Repeat("é", 300): strings.Repeat("é", 127), + "": "", + "..": "", + } { + require.Equal(t, want, sanitizeFilename(in), "%q", in) + } +} diff --git a/cmd/api/handlers/handlers.go b/cmd/api/handlers/handlers.go index 2085ab7fd..8ddab0ea0 100644 --- a/cmd/api/handlers/handlers.go +++ b/cmd/api/handlers/handlers.go @@ -14,6 +14,7 @@ import ( "github.com/jmpsec/osctrl/pkg/console" "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/events" + "github.com/jmpsec/osctrl/pkg/extensions" "github.com/jmpsec/osctrl/pkg/fileexplorer" "github.com/jmpsec/osctrl/pkg/geoip" "github.com/jmpsec/osctrl/pkg/health" @@ -95,6 +96,7 @@ type HandlersApi struct { Vulns *vulns.Reader // nil when --vuln-enabled is off NodeConfig *nodeconfig.Manager FlagsOverrides *nodeconfig.FlagsManager + Extensions *extensions.Manager // MFA owns the second-factor tables. Nil disables every MFA route. MFA *mfa.Manager // WebAuthn is nil when no RP ID or origin could be resolved, which @@ -323,6 +325,13 @@ func WithFlagsOverrides(m *nodeconfig.FlagsManager) HandlersOption { } } +// WithExtensions wires the osquery extensions library. +func WithExtensions(m *extensions.Manager) HandlersOption { + return func(h *HandlersApi) { + h.Extensions = m + } +} + // WithVulns wires the vulnerability read model (nil when disabled). func WithVulns(r *vulns.Reader) HandlersOption { return func(h *HandlersApi) { diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index 3d4e71ab2..8391303a1 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -324,6 +324,11 @@ func (h *HandlersApi) DeleteNodeHandler(w http.ResponseWriter, r *http.Request) log.Err(err).Msgf("error removing flags override of deleted node %s", node.UUID) } } + if h.Extensions != nil { + if err := h.Extensions.DeleteNode(node.UUID); err != nil { + log.Err(err).Msgf("error removing extensions of deleted node %s", node.UUID) + } + } log.Debug().Msgf("Deleted node %s", n.UUID) h.AuditLog.NodeAction(ctx[ctxUser], "deleted node "+n.UUID, strings.Split(r.RemoteAddr, ":")[0], env.ID) // Serialize and serve JSON diff --git a/cmd/api/main.go b/cmd/api/main.go index 4341d4571..9470fd902 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -28,6 +28,7 @@ import ( "github.com/jmpsec/osctrl/pkg/dbutil" "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/events" + "github.com/jmpsec/osctrl/pkg/extensions" "github.com/jmpsec/osctrl/pkg/fileexplorer" "github.com/jmpsec/osctrl/pkg/geoip" "github.com/jmpsec/osctrl/pkg/health" @@ -122,6 +123,8 @@ const ( // API log sinks path apiLogSinksPath = "/log-sinks" apiAlertsPath = "/alerts" + // API osquery extensions library path + apiExtensionsPath = "/extensions" // API health / system status path apiHealthPath = "/health" apiAuthProvidersPath = "/auth-providers" @@ -460,6 +463,10 @@ func osctrlAPIService() { if err != nil { log.Fatal().Err(err).Msg("Failed to initialize node flags overrides") } + extensionsMgr, err := extensions.NewManager(db.Conn) + if err != nil { + log.Fatal().Err(err).Msg("Failed to initialize osquery extensions") + } log.Info().Msg("Initialize queries") queriesmgr = queries.CreateQueries(db.Conn) queriesmgr.Cache = queries.NewQueryDispatchCache(redis.Client, 0) @@ -690,6 +697,7 @@ func osctrlAPIService() { handlers.WithNodes(nodesmgr), handlers.WithNodeConfig(nodeConfigMgr), handlers.WithFlagsOverrides(nodeFlagsMgr), + handlers.WithExtensions(extensionsMgr), handlers.WithQueries(queriesmgr), handlers.WithConsole(consolemgr), handlers.WithFileExplorer(fileexplorermgr), @@ -858,6 +866,35 @@ func osctrlAPIService() { muxAPI.Handle( "DELETE "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/flags", handlerAuthCheck(http.HandlerFunc(handlersApi.NodeFlagsDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: osquery extensions library (super admin) + muxAPI.Handle( + "GET "+_apiPath(apiExtensionsPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.ExtensionsListHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiExtensionsPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.ExtensionsCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PATCH "+_apiPath(apiExtensionsPath)+"/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ExtensionsUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiExtensionsPath)+"/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ExtensionsDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PUT "+_apiPath(apiExtensionsPath)+"/{id}/files/{platform}/{arch}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ExtensionFilePutHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiExtensionsPath)+"/{id}/files/{platform}/{arch}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ExtensionFileDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: node extensions (view: environment admin; assign/remove: super admin) + muxAPI.Handle( + "GET "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/extensions", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodeExtensionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PUT "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/extensions/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodeExtensionAssignHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/extensions/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodeExtensionRemoveHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiNodesPath)+"/{env}/delete", handlerAuthCheck(http.HandlerFunc(handlersApi.DeleteNodeHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) @@ -1209,6 +1246,17 @@ func osctrlAPIService() { muxAPI.Handle( "PATCH "+_apiPath(apiEnvironmentsPath)+"/packages/{env}/{id}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvPackageUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: environment extensions (environment admin). Same shape as packages: + // /{env}/extensions would overlap /map/{target} and friends in ServeMux. + muxAPI.Handle( + "GET "+_apiPath(apiEnvironmentsPath)+"/extensions/{env}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvExtensionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PUT "+_apiPath(apiEnvironmentsPath)+"/extensions/{env}/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvExtensionAssignHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiEnvironmentsPath)+"/extensions/{env}/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvExtensionRemoveHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) // API: tags by environment muxAPI.Handle( "GET "+_apiPath(apiTagsPath), diff --git a/cmd/cli/node.go b/cmd/cli/node.go index 1b4c52247..7245d580b 100644 --- a/cmd/cli/node.go +++ b/cmd/cli/node.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/jmpsec/osctrl/pkg/extensions" "github.com/jmpsec/osctrl/pkg/nodeconfig" "github.com/jmpsec/osctrl/pkg/nodes" "github.com/jmpsec/osctrl/pkg/tags" @@ -17,8 +18,9 @@ import ( "github.com/urfave/cli/v3" ) -// deleteNodeOverride removes the config and flags overrides and the osctrld -// status of a node just deleted by UUID. ArchiveDeleteByUUID matches the +// deleteNodeOverride removes the config and flags overrides, the osctrld +// status and the extension assignments and statuses of a node just deleted by +// UUID. ArchiveDeleteByUUID matches the // uppercased UUID, so that is the node row's key. A failure is logged, not // returned: the node is already gone. // ponytail: the CLI has no Redis, so osctrl-tls may keep a cached copy for up @@ -39,6 +41,13 @@ func deleteNodeOverride(uuid string) { if err != nil { log.Err(err).Msgf("error removing flags override of deleted node %s", uuid) } + e, err := extensions.NewManager(nodesmgr.DB) + if err == nil { + err = e.DeleteNode(uuid) + } + if err != nil { + log.Err(err).Msgf("error removing extensions of deleted node %s", uuid) + } } // Helper function to convert a slice of nodes into the data expected for output diff --git a/cmd/cli/node_delete_test.go b/cmd/cli/node_delete_test.go index 4db58e64d..233d38cfa 100644 --- a/cmd/cli/node_delete_test.go +++ b/cmd/cli/node_delete_test.go @@ -2,12 +2,15 @@ package main import ( "context" + "strings" "testing" "time" "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/extensions" "github.com/jmpsec/osctrl/pkg/nodeconfig" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/types" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" "gorm.io/driver/sqlite" @@ -15,7 +18,8 @@ import ( ) // setupNodeDelete wires the CLI's DB-mode globals to a fresh database holding -// node NODE-A with a config override, a flags override and an osctrld status. +// node NODE-A with a config override, a flags override, an osctrld status and +// a command-execution extension assignment and status. func setupNodeDelete(t *testing.T) (*nodeconfig.Manager, *gorm.DB) { t.Helper() oldNodes, oldAudit, oldDB, oldSilent := nodesmgr, auditlogsmgr, dbFlag, silentFlag @@ -36,17 +40,26 @@ func setupNodeDelete(t *testing.T) (*nodeconfig.Manager, *gorm.DB) { _, err = fm.Set(context.Background(), nodeconfig.FlagsOverride{NodeUUID: "NODE-A", EnvironmentID: 1, Flags: "--verbose=true"}) require.NoError(t, err) require.NoError(t, fm.RecordFetch("NODE-A", 1, time.Now(), nil)) + em, err := extensions.NewManager(db) + require.NoError(t, err) + ext, err := em.Create(extensions.Extension{Name: "cmd", CommandExecution: true}) + require.NoError(t, err) + _, err = em.AssignNode("NODE-A", 1, ext.ID, nil, "root") + require.NoError(t, err) + require.NoError(t, em.RecordStatus("NODE-A", 1, []types.OsctrldExtensionStatus{ + {Name: "cmd", SHA256: strings.Repeat("a", 64), Result: "ok", At: time.Now()}, + }, time.Now())) return mgr, db } -// requireNodeRowsGone checks the node's config override, flags override and -// osctrld status are hard-deleted. +// requireNodeRowsGone checks the node's config override, flags override, +// osctrld status and extension assignment and status are hard-deleted. func requireNodeRowsGone(t *testing.T, mgr *nodeconfig.Manager, db *gorm.DB) { t.Helper() o, err := mgr.Get("NODE-A") require.NoError(t, err) require.Nil(t, o) - for _, model := range []any{&nodeconfig.FlagsOverride{}, &nodeconfig.OsctrldStatus{}} { + for _, model := range []any{&nodeconfig.FlagsOverride{}, &nodeconfig.OsctrldStatus{}, &extensions.NodeExtension{}, &extensions.NodeExtensionStatus{}} { var n int64 require.NoError(t, db.Unscoped().Model(model).Count(&n).Error) require.Zero(t, n, "%T", model) diff --git a/cmd/tls/handlers/extensions.go b/cmd/tls/handlers/extensions.go new file mode 100644 index 000000000..d220bb249 --- /dev/null +++ b/cmd/tls/handlers/extensions.go @@ -0,0 +1,154 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" +) + +const ( + maxBodyExtensions = 16 * 1024 // 16 KiB — osctrld extension list request with status + maxBodyExtensionDownload = 1024 // 1 KiB — osctrld extension download request +) + +// osctrldRequest checks the environment in the path, reads a body of at most +// max bytes into v and checks its secret. It writes the error response and +// returns false when any step fails. +func (h *HandlersTLS) osctrldRequest(w http.ResponseWriter, r *http.Request, max int64, v any, secret func() string) (environments.TLSEnvironment, bool) { + envVar := r.PathValue("env") + if !utils.CheckUUID(envVar) { + utils.HTTPResponse(w, "", http.StatusBadRequest, []byte("")) + return environments.TLSEnvironment{}, false + } + env, err := h.EnvCache.GetByUUID(r.Context(), envVar) + if err != nil { + log.Err(err).Msg("error getting environment") + utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) + return env, false + } + if h.debugHTTPAll() { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + body, err := readBody(w, r, max) + if err != nil { + log.Err(err).Msg("error reading POST body") + if !isMaxBytesError(err) { + utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) + } + return env, false + } + if err := json.Unmarshal(body, v); err != nil { + log.Err(err).Msg("error parsing POST body") + utils.HTTPResponse(w, "", http.StatusBadRequest, []byte("")) + return env, false + } + if !h.checkValidSecret(secret(), env) { + utils.HTTPResponse(w, "", http.StatusForbidden, []byte("")) + return env, false + } + return env, true +} + +// ExtensionsHandler returns the osquery extensions osctrld should run: the +// environment's, plus the node's own when the request names a node of this +// environment. It records the extension status that node reports. +func (h *HandlersTLS) ExtensionsHandler(w http.ResponseWriter, r *http.Request) { + var t types.OsctrldExtensionsRequest + env, ok := h.osctrldRequest(w, r, maxBodyExtensions, &t, func() string { return t.Secret }) + if !ok { + return + } + if t.Platform == "" && t.Arch == "" { + // An osctrld from before extensions: nothing to serve, nothing to record + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, []types.OsctrldExtensionEntry{}) + return + } + if !extensions.ValidPlatform(t.Platform, t.Arch) { + utils.HTTPResponse(w, "", http.StatusBadRequest, []byte("")) + return + } + now := time.Now() + // The UUID is untrusted: only a node of this environment gets its own + // extensions, keyed by the stored (upper-case) UUID. + nodeUUID := "" + if utils.CheckUUID(t.UUID) { + if node, err := h.Nodes.GetByUUIDEnv(t.UUID, env.ID); err == nil { + nodeUUID = node.UUID + } + } + if nodeUUID != "" && len(t.Status) > 0 { + if err := h.Extensions.RecordStatus(nodeUUID, env.ID, t.Status, now); err != nil { + log.Err(err).Msgf("node UUID: %s in %s environment: error recording osctrld extension status", nodeUUID, env.Name) + } + } + entries, err := h.Extensions.EffectiveSet(env.ID, nodeUUID, t.Platform, t.Arch, now) + if err != nil { + log.Err(err).Msgf("error getting extensions for %s environment", env.Name) + utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) + return + } + for i := range entries { + entries[i].URL = "/" + env.UUID + "/" + environments.DefaultExtensionPath + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, entries) +} + +// ExtensionDownloadHandler streams one extension binary assigned in the +// environment, chunk by chunk. +func (h *HandlersTLS) ExtensionDownloadHandler(w http.ResponseWriter, r *http.Request) { + var t types.OsctrldDownloadRequest + env, ok := h.osctrldRequest(w, r, maxBodyExtensionDownload, &t, func() string { return t.Secret }) + if !ok { + return + } + sha := t.SHA256 + if !extensions.ValidSHA256(sha) { + utils.HTTPResponse(w, "", http.StatusBadRequest, []byte("")) + return + } + in, err := h.Extensions.InEnvSet(env.ID, sha) + if err != nil { + log.Err(err).Msgf("error checking extension %s for %s environment", sha, env.Name) + utils.HTTPResponse(w, "", http.StatusInternalServerError, []byte("")) + return + } + if !in { + utils.HTTPResponse(w, "", http.StatusNotFound, []byte("")) + return + } + size, err := h.Extensions.FileSize(sha) + if err != nil { + code := http.StatusInternalServerError + if errors.Is(err, extensions.ErrNotFound) { + code = http.StatusNotFound + } + utils.HTTPResponse(w, "", code, []byte("")) + return + } + w.Header().Set(utils.ContentType, utils.OctetStream) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + n, err := h.Extensions.StreamFile(r.Context(), sha, w) + if err == nil { + return + } + if n == 0 { + // Nothing sent yet (e.g. replaced since the checks): headers can still change. + w.Header().Del("Content-Length") + code := http.StatusInternalServerError + if errors.Is(err, extensions.ErrNotFound) { + code = http.StatusNotFound + } + utils.HTTPResponse(w, "", code, []byte("")) + return + } + // Mid-stream: the short body against Content-Length tells osctrld it failed. + log.Debug().Err(err).Msgf("extension %s for %s environment: stream stopped after %d bytes", sha, env.Name, n) +} diff --git a/cmd/tls/handlers/extensions_test.go b/cmd/tls/handlers/extensions_test.go new file mode 100644 index 000000000..9c374d060 --- /dev/null +++ b/cmd/tls/handlers/extensions_test.go @@ -0,0 +1,276 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type extFixture struct { + h *HandlersTLS + m *extensions.Manager + env environments.TLSEnvironment + envExt *extensions.Extension // ordinary, assigned to env + envFile, + nodeFile, // command-execution, assigned to nfNodeA in env + otherFile *extensions.ExtensionFile // ordinary, assigned to the other environment + envData []byte +} + +func newExtFixture(t *testing.T) *extFixture { + t.Helper() + // Shared-cache named DB: every pooled connection sees the same data. + dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{TranslateError: true}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + t.Cleanup(func() { _ = sqlDB.Close() }) + envs := environments.CreateEnvironment(db) + nodesRepo := nodes.CreateNodes(db) + t.Cleanup(nodesRepo.Cache.Close) + env := environments.TLSEnvironment{UUID: "11111111-1111-4111-8111-111111111111", Name: "env", Secret: "osctrld-test-secret", Hostname: "osctrl.example"} + other := environments.TLSEnvironment{UUID: "22222222-2222-4222-8222-222222222222", Name: "other", Secret: "other-secret", Hostname: "osctrl.example"} + require.NoError(t, db.Create(&env).Error) + require.NoError(t, db.Create(&other).Error) + for _, n := range []nodes.OsqueryNode{ + {NodeKey: "key-a", UUID: nfNodeA, EnvironmentID: env.ID}, + {NodeKey: "key-other", UUID: nfNodeOther, EnvironmentID: other.ID}, + } { + require.NoError(t, db.Create(&n).Error) + } + m, err := extensions.NewManager(db) + require.NoError(t, err) + + f := &extFixture{m: m, env: env, envData: bytes.Repeat([]byte("osquery-extension"), extensions.ChunkSize/8)} + put := func(name string, cmdExec bool, data []byte) (*extensions.Extension, *extensions.ExtensionFile) { + e, err := m.Create(extensions.Extension{Name: name, CommandExecution: cmdExec, CreatedBy: "admin"}) + require.NoError(t, err) + file, err := m.PutFile(context.Background(), e.ID, "linux", "amd64", name+".ext", bytes.NewReader(data), "admin") + require.NoError(t, err) + return e, file + } + f.envExt, f.envFile = put("envext", false, f.envData) + require.NoError(t, m.AssignEnv(env.ID, f.envExt.ID, "admin")) + nodeExt, nodeFile := put("cmdext", true, []byte("command execution")) + f.nodeFile = nodeFile + _, err = m.AssignNode(nfNodeA, env.ID, nodeExt.ID, nil, "admin") + require.NoError(t, err) + // An assignment stored for the foreign node in this environment must not + // be reachable by naming that node against this environment. + _, err = m.AssignNode(nfNodeOther, env.ID, nodeExt.ID, nil, "admin") + require.NoError(t, err) + otherExt, otherFile := put("otherext", false, []byte("other environment")) + f.otherFile = otherFile + require.NoError(t, m.AssignEnv(other.ID, otherExt.ID, "admin")) + + f.h = CreateHandlersTLS(WithEnvs(envs), WithNodes(nodesRepo), WithExtensions(m)) + return f +} + +func extPost(h http.HandlerFunc, env environments.TLSEnvironment, req any) *httptest.ResponseRecorder { + body, _ := json.Marshal(req) + r := httptest.NewRequest(http.MethodPost, "/osctrld", bytes.NewReader(body)) + r.SetPathValue("env", env.UUID) + w := httptest.NewRecorder() + h(w, r) + return w +} + +func (f *extFixture) list(t *testing.T, req types.OsctrldExtensionsRequest) []types.OsctrldExtensionEntry { + t.Helper() + w := extPost(f.h.ExtensionsHandler, f.env, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var out []types.OsctrldExtensionEntry + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + return out +} + +func (f *extFixture) entry(name string, file *extensions.ExtensionFile) types.OsctrldExtensionEntry { + return types.OsctrldExtensionEntry{Name: name, SHA256: file.SHA256, Size: file.Size, URL: "/" + f.env.UUID + "/osctrld-extension"} +} + +func (f *extFixture) statuses(t *testing.T, uuid string) []extensions.NodeExtensionStatus { + t.Helper() + st, err := f.m.NodeStatuses(uuid) + require.NoError(t, err) + return st +} + +func (f *extFixture) req(uuid string, status ...types.OsctrldExtensionStatus) types.OsctrldExtensionsRequest { + return types.OsctrldExtensionsRequest{Secret: f.env.Secret, UUID: uuid, Platform: "linux", Arch: "amd64", Status: status} +} + +func TestExtensionsListEnvAndNode(t *testing.T) { + f := newExtFixture(t) + envOnly := []types.OsctrldExtensionEntry{f.entry("envext", f.envFile)} + require.Equal(t, envOnly, f.list(t, f.req(""))) + both := []types.OsctrldExtensionEntry{f.entry("cmdext", f.nodeFile), f.entry("envext", f.envFile)} + require.Equal(t, both, f.list(t, f.req(nfNodeA))) + // osquery reports UUIDs in either case; the lookup must still match. + require.Equal(t, both, f.list(t, f.req(strings.ToLower(nfNodeA)))) +} + +func TestExtensionsListUnknownOrForeignUUID(t *testing.T) { + f := newExtFixture(t) + st := types.OsctrldExtensionStatus{Name: "envext", SHA256: f.envFile.SHA256, Result: "ok", At: time.Now()} + envOnly := []types.OsctrldExtensionEntry{f.entry("envext", f.envFile)} + for _, uuid := range []string{nfNodeOther, nfUnknown, "not-a-uuid"} { + require.Equal(t, envOnly, f.list(t, f.req(uuid, st)), uuid) + require.Empty(t, f.statuses(t, uuid), uuid) + require.Empty(t, f.statuses(t, strings.ToUpper(uuid)), uuid) + } +} + +func TestExtensionsListBadPlatform(t *testing.T) { + f := newExtFixture(t) + for _, pa := range [][2]string{{"plan9", "amd64"}, {"linux", "universal"}, {"windows", "arm64"}, {"", "amd64"}, {"linux", ""}, {"Linux", "amd64"}} { + req := f.req(nfNodeA) + req.Platform, req.Arch = pa[0], pa[1] + w := extPost(f.h.ExtensionsHandler, f.env, req) + require.Equal(t, http.StatusBadRequest, w.Code, pa) + } +} + +func TestExtensionsListWrongSecret(t *testing.T) { + f := newExtFixture(t) + req := f.req(nfNodeA, types.OsctrldExtensionStatus{Name: "envext", SHA256: f.envFile.SHA256, Result: "ok", At: time.Now()}) + for _, secret := range []string{"", "wrong", "other-secret"} { + req.Secret = secret + w := extPost(f.h.ExtensionsHandler, f.env, req) + require.Equal(t, http.StatusForbidden, w.Code, secret) + require.NotContains(t, w.Body.String(), f.envFile.SHA256) + } + require.Empty(t, f.statuses(t, nfNodeA)) +} + +func TestExtensionsListRecordsValidStatusOnly(t *testing.T) { + f := newExtFixture(t) + at := time.Now().Add(-time.Minute).UTC().Truncate(time.Second) + f.list(t, f.req(strings.ToLower(nfNodeA), + types.OsctrldExtensionStatus{Name: "envext", SHA256: f.envFile.SHA256, Result: "rejected", Error: "bad signature", At: at}, + types.OsctrldExtensionStatus{Name: "cmdext", SHA256: f.nodeFile.SHA256, Result: "maybe", At: at}, + types.OsctrldExtensionStatus{Name: "otherext", SHA256: "not-a-sha", Result: "ok", At: at}, + types.OsctrldExtensionStatus{Name: "unknown", SHA256: f.envFile.SHA256, Result: "ok", At: at}, + types.OsctrldExtensionStatus{Name: "../evil", SHA256: f.envFile.SHA256, Result: "ok", At: at}, + )) + // Stored under the node row's upper-case UUID, not the request value. + require.Empty(t, f.statuses(t, strings.ToLower(nfNodeA))) + st := f.statuses(t, nfNodeA) + require.Len(t, st, 1) + require.Equal(t, "envext", st[0].Name) + require.Equal(t, f.env.ID, st[0].EnvironmentID) + require.Equal(t, "rejected", st[0].Result) + require.Equal(t, "bad signature", st[0].Error) + require.NotNil(t, st[0].At) + require.True(t, at.Equal(*st[0].At)) +} + +func (f *extFixture) download(sha, secret string) *httptest.ResponseRecorder { + return extPost(f.h.ExtensionDownloadHandler, f.env, types.OsctrldDownloadRequest{Secret: secret, SHA256: sha}) +} + +func TestExtensionDownloadStreams(t *testing.T) { + f := newExtFixture(t) + require.Greater(t, len(f.envData), extensions.ChunkSize, "payload must span chunks") + w := f.download(f.envFile.SHA256, f.env.Secret) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "application/octet-stream", w.Header().Get("Content-Type")) + require.Equal(t, strconv.Itoa(len(f.envData)), w.Header().Get("Content-Length")) + require.True(t, bytes.Equal(f.envData, w.Body.Bytes())) + // A node-assigned command-execution file is downloadable in its environment. + w = f.download(f.nodeFile.SHA256, f.env.Secret) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "command execution", w.Body.String()) +} + +func TestExtensionDownloadNotInEnv(t *testing.T) { + f := newExtFixture(t) + for _, sha := range []string{f.otherFile.SHA256, strings.Repeat("a", 64)} { + w := f.download(sha, f.env.Secret) + require.Equal(t, http.StatusNotFound, w.Code, sha) + require.Empty(t, w.Header().Get("Content-Length")) + } +} + +func TestExtensionDownloadReplacedFile(t *testing.T) { + f := newExtFixture(t) + old := f.envFile.SHA256 + nf, err := f.m.PutFile(context.Background(), f.envExt.ID, "linux", "amd64", "v2.ext", strings.NewReader("version two"), "admin") + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, f.download(old, f.env.Secret).Code) + w := f.download(nf.SHA256, f.env.Secret) + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, "version two", w.Body.String()) +} + +func TestExtensionDownloadBadSHA(t *testing.T) { + f := newExtFixture(t) + for _, sha := range []string{"", "abc", strings.ToUpper(f.envFile.SHA256), f.envFile.SHA256 + "0", "../" + f.envFile.SHA256[3:], strings.Repeat("g", 64)} { + require.Equal(t, http.StatusBadRequest, f.download(sha, f.env.Secret).Code, sha) + } +} + +func TestExtensionDownloadWrongSecret(t *testing.T) { + f := newExtFixture(t) + for _, secret := range []string{"", "wrong", "other-secret"} { + w := f.download(f.envFile.SHA256, secret) + require.Equal(t, http.StatusForbidden, w.Code, secret) + require.Empty(t, w.Header().Get("Content-Length")) + require.NotContains(t, w.Body.String(), "osquery-extension") + } +} + +// goneWriter is a client that disconnects after the first chunk. +type goneWriter struct { + header http.Header + writes int +} + +func (g *goneWriter) Header() http.Header { return g.header } +func (g *goneWriter) WriteHeader(int) {} +func (g *goneWriter) Write(b []byte) (int, error) { + g.writes++ + if g.writes > 1 { + return 0, errors.New("broken pipe") + } + return len(b), nil +} + +func TestExtensionDownloadClientGone(t *testing.T) { + f := newExtFixture(t) + body, _ := json.Marshal(types.OsctrldDownloadRequest{Secret: f.env.Secret, SHA256: f.envFile.SHA256}) + r := httptest.NewRequest(http.MethodPost, "/osctrld", bytes.NewReader(body)) + r.SetPathValue("env", f.env.UUID) + g := &goneWriter{header: http.Header{}} + f.h.ExtensionDownloadHandler(g, r) + require.Greater(t, len(f.envData), 2*extensions.ChunkSize, "payload must have a chunk left after the failure") + require.Equal(t, 2, g.writes, "streaming must stop at the first failed write") +} + +// An osctrld from before extensions sends neither platform nor arch: it gets +// an empty list, not an error every sync, and its status is not recorded. +func TestExtensionsListNoPlatform(t *testing.T) { + f := newExtFixture(t) + req := f.req(nfNodeA, types.OsctrldExtensionStatus{Name: "envext", SHA256: f.envFile.SHA256, Result: "ok", At: time.Now()}) + req.Platform, req.Arch = "", "" + w := extPost(f.h.ExtensionsHandler, f.env, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.JSONEq(t, "[]", w.Body.String()) + require.Empty(t, f.statuses(t, nfNodeA)) +} diff --git a/cmd/tls/handlers/handlers.go b/cmd/tls/handlers/handlers.go index 89d4a3e1f..ca7e629fc 100644 --- a/cmd/tls/handlers/handlers.go +++ b/cmd/tls/handlers/handlers.go @@ -14,6 +14,7 @@ import ( "github.com/jmpsec/osctrl/pkg/config" "github.com/jmpsec/osctrl/pkg/console" "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" "github.com/jmpsec/osctrl/pkg/fileexplorer" "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/nodeconfig" @@ -81,6 +82,7 @@ type HandlersTLS struct { AuditLog *auditlog.AuditLogManager NodeConfig *nodeconfig.Manager // nil: per-node config overrides are inert FlagsOverrides *nodeconfig.FlagsManager // nil: per-node flags overrides are inert + Extensions *extensions.Manager // nil: osctrld extension routes are not registered // DBHealth, when wired via WithDBHealth, lets HealthHandler // report backend degradation. nil means "no monitor" and the // health endpoint reports healthy as before. @@ -155,6 +157,13 @@ func WithFlagsOverrides(m *nodeconfig.FlagsManager) Option { } } +// WithExtensions enables serving osquery extensions to osctrld. +func WithExtensions(m *extensions.Manager) Option { + return func(h *HandlersTLS) { + h.Extensions = m + } +} + // WithNodes to pass value as option func WithNodes(nodes *nodes.NodeManager) Option { return func(h *HandlersTLS) { diff --git a/cmd/tls/main.go b/cmd/tls/main.go index 2c36f609b..b0ac3a1de 100644 --- a/cmd/tls/main.go +++ b/cmd/tls/main.go @@ -24,6 +24,7 @@ import ( "github.com/jmpsec/osctrl/pkg/dbutil" "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/events" + "github.com/jmpsec/osctrl/pkg/extensions" "github.com/jmpsec/osctrl/pkg/health" "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/logsinks" @@ -266,6 +267,12 @@ func osctrlService() { if err != nil { log.Fatal().Err(err).Msg("Failed to initialize node flags overrides") } + var extensionsMgr *extensions.Manager + if flagParams.Osctrld.Enabled { + if extensionsMgr, err = extensions.NewManager(db.Conn); err != nil { + log.Fatal().Err(err).Msg("Failed to initialize osquery extensions") + } + } var posturemgr *posture.PostureManager if flagParams.Service.PostureEnabled { posture.SetPrefix(flagParams.Service.PostureQueryPrefix) @@ -537,6 +544,7 @@ func osctrlService() { handlers.WithNodes(nodesmgr), handlers.WithNodeConfig(nodeConfigMgr), handlers.WithFlagsOverrides(nodeFlagsMgr), + handlers.WithExtensions(extensionsMgr), handlers.WithTags(tagsmgr), handlers.WithQueries(queriesmgr), handlers.WithCarves(filecarves), @@ -556,14 +564,6 @@ func osctrlService() { ) // ///////////////////////// ALL CONTENT IS UNAUTHENTICATED FOR TLS log.Info().Msg("Initializing router") - // Create router for TLS endpoint - muxTLS := http.NewServeMux() - // TLS: root - muxTLS.HandleFunc("GET /", handlersTLS.RootHandler) - // TLS: testing - muxTLS.HandleFunc("GET "+healthPath, handlersTLS.HealthHandler) - // TLS: error - muxTLS.HandleFunc("GET "+errorPath, handlersTLS.ErrorHandler) // TLS: Specific routes for osquery nodes // FIXME this forces all paths to be the same // Rate-limit + Prometheus around the enroll handler. The limiter sees @@ -585,46 +585,8 @@ func osctrlService() { } auditLog.FailedEnroll(key, envName, "rate limit exceeded", 0) }) - muxTLS.Handle("POST /{env}/"+environments.DefaultEnrollPath, enrollRateLimit(handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.EnrollHandler)))) - if flagParams.Osquery.Config { - muxTLS.Handle("POST /{env}/"+environments.DefaultConfigPath, handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.ConfigHandler))) - } - if flagParams.Osquery.Logger { - muxTLS.Handle("POST /{env}/"+environments.DefaultLogPath, handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.LogHandler))) - } - if flagParams.Osquery.Query { - muxTLS.Handle("POST /{env}/"+environments.DefaultQueryReadPath, handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.QueryReadHandler))) - muxTLS.Handle("POST /{env}/"+environments.DefaultQueryWritePath, handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.QueryWriteHandler))) - } - if flagParams.Osquery.Carve { - muxTLS.Handle("POST /{env}/"+environments.DefaultCarverInitPath, handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.CarveInitHandler))) - muxTLS.Handle("POST /{env}/"+environments.DefaultCarverBlockPath, handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.CarveBlockHandler))) - } - // TLS: Quick enroll/remove script - muxTLS.HandleFunc("GET /{env}/{secretpath}/{script}", handlersTLS.QuickEnrollHandler) - // TLS: Download enrolling package - muxTLS.HandleFunc("GET /{env}/{secretpath}/package/{package}", handlersTLS.EnrollPackageHandler) - // TLS: Download enrolling package by architecture - muxTLS.HandleFunc("GET /{env}/{secretpath}/package/{package}/{arch}", handlersTLS.EnrollPackageHandler) - - // Enable osctrld endpoints - if flagParams.Osctrld.Enabled { - log.Info().Msg("Enabling osctrld endpoints") - // TLS: osctrld retrieve flags - muxTLS.HandleFunc("POST /{env}/"+environments.DefaultFlagsPath, handlersTLS.FlagsHandler) - // TLS: osctrld retrieve certificate - muxTLS.HandleFunc("POST /{env}/"+environments.DefaultCertPath, handlersTLS.CertHandler) - // TLS: osctrld verification - muxTLS.HandleFunc("POST /{env}/"+environments.DefaultVerifyPath, handlersTLS.VerifyHandler) - // TLS: osctrld retrieve script to install/remove osquery - muxTLS.HandleFunc("POST /{env}/{action}/{platform}/"+environments.DefaultScriptPath, handlersTLS.ScriptHandler) - } - - // Enable configuration endpoints if passed via YAML configuration - if flagParams.ConfigEndpoints != nil && len(*flagParams.ConfigEndpoints) > 0 { - log.Info().Msgf("Enabling %d configuration endpoints", len(*flagParams.ConfigEndpoints)) - muxTLS.HandleFunc("POST /{env}/{secret}/"+environments.DefaultConfigEndpointPath, handlersTLS.OsqueryConfigEndpointHandler) - } + muxTLS := http.NewServeMux() + registerRoutes(muxTLS, handlersTLS, flagParams, enrollRateLimit(handlersTLS.PrometheusMiddleware(http.HandlerFunc(handlersTLS.EnrollHandler)))) // ////////////////////////////// Everything is ready at this point! serviceListener := flagParams.Service.Listener + ":" + strconv.Itoa(flagParams.Service.Port) @@ -1016,3 +978,60 @@ func main() { os.Exit(1) } } + +// registerRoutes registers every osctrl-tls route on mux. enroll is the +// rate-limited enroll handler. Patterns must not overlap without one being +// more specific, or ServeMux panics at startup (see routes_test.go). +func registerRoutes(mux *http.ServeMux, h *handlers.HandlersTLS, p *config.ServiceParameters, enroll http.Handler) { + // TLS: root + mux.HandleFunc("GET /", h.RootHandler) + // TLS: testing + mux.HandleFunc("GET "+healthPath, h.HealthHandler) + // TLS: error + mux.HandleFunc("GET "+errorPath, h.ErrorHandler) + mux.Handle("POST /{env}/"+environments.DefaultEnrollPath, enroll) + if p.Osquery.Config { + mux.Handle("POST /{env}/"+environments.DefaultConfigPath, h.PrometheusMiddleware(http.HandlerFunc(h.ConfigHandler))) + } + if p.Osquery.Logger { + mux.Handle("POST /{env}/"+environments.DefaultLogPath, h.PrometheusMiddleware(http.HandlerFunc(h.LogHandler))) + } + if p.Osquery.Query { + mux.Handle("POST /{env}/"+environments.DefaultQueryReadPath, h.PrometheusMiddleware(http.HandlerFunc(h.QueryReadHandler))) + mux.Handle("POST /{env}/"+environments.DefaultQueryWritePath, h.PrometheusMiddleware(http.HandlerFunc(h.QueryWriteHandler))) + } + if p.Osquery.Carve { + mux.Handle("POST /{env}/"+environments.DefaultCarverInitPath, h.PrometheusMiddleware(http.HandlerFunc(h.CarveInitHandler))) + mux.Handle("POST /{env}/"+environments.DefaultCarverBlockPath, h.PrometheusMiddleware(http.HandlerFunc(h.CarveBlockHandler))) + } + // TLS: Quick enroll/remove script + mux.HandleFunc("GET /{env}/{secretpath}/{script}", h.QuickEnrollHandler) + // TLS: Download enrolling package + mux.HandleFunc("GET /{env}/{secretpath}/package/{package}", h.EnrollPackageHandler) + // TLS: Download enrolling package by architecture + mux.HandleFunc("GET /{env}/{secretpath}/package/{package}/{arch}", h.EnrollPackageHandler) + + // Enable osctrld endpoints + if p.Osctrld.Enabled { + log.Info().Msg("Enabling osctrld endpoints") + // TLS: osctrld retrieve flags + mux.HandleFunc("POST /{env}/"+environments.DefaultFlagsPath, h.FlagsHandler) + // TLS: osctrld retrieve certificate + mux.HandleFunc("POST /{env}/"+environments.DefaultCertPath, h.CertHandler) + // TLS: osctrld verification + mux.HandleFunc("POST /{env}/"+environments.DefaultVerifyPath, h.VerifyHandler) + // TLS: osctrld retrieve script to install/remove osquery + mux.HandleFunc("POST /{env}/{action}/{platform}/"+environments.DefaultScriptPath, h.ScriptHandler) + if h.Extensions != nil { + // TLS: osctrld list and download osquery extensions + mux.HandleFunc("POST /{env}/"+environments.DefaultExtensionsPath, h.ExtensionsHandler) + mux.HandleFunc("POST /{env}/"+environments.DefaultExtensionPath, h.ExtensionDownloadHandler) + } + } + + // Enable configuration endpoints if passed via YAML configuration + if p.ConfigEndpoints != nil && len(*p.ConfigEndpoints) > 0 { + log.Info().Msgf("Enabling %d configuration endpoints", len(*p.ConfigEndpoints)) + mux.HandleFunc("POST /{env}/{secret}/"+environments.DefaultConfigEndpointPath, h.OsqueryConfigEndpointHandler) + } +} diff --git a/cmd/tls/routes_test.go b/cmd/tls/routes_test.go new file mode 100644 index 000000000..65882390a --- /dev/null +++ b/cmd/tls/routes_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/jmpsec/osctrl/cmd/tls/handlers" + "github.com/jmpsec/osctrl/pkg/config" + "github.com/jmpsec/osctrl/pkg/environments" + "github.com/jmpsec/osctrl/pkg/extensions" + "github.com/stretchr/testify/require" +) + +// TestRegisterRoutesAllEnabled registers every route at once: Go's ServeMux +// panics at startup when two patterns overlap with neither more specific. +func TestRegisterRoutesAllEnabled(t *testing.T) { + params := &config.ServiceParameters{ + Osquery: &config.YAMLConfigurationOsquery{Config: true, Logger: true, Query: true, Carve: true}, + ConfigEndpoints: &config.YAMLConfigurationEndpoints{{}}, + Osctrld: &config.YAMLConfigurationOsctrld{Enabled: true}, + } + h := &handlers.HandlersTLS{Extensions: &extensions.Manager{}} + mux := http.NewServeMux() + require.NotPanics(t, func() { registerRoutes(mux, h, params, http.NotFoundHandler()) }) + + const env = "/11111111-1111-4111-8111-111111111111/" + for path, want := range map[string]string{ + env + environments.DefaultExtensionsPath: "POST /{env}/" + environments.DefaultExtensionsPath, + env + environments.DefaultExtensionPath: "POST /{env}/" + environments.DefaultExtensionPath, + env + "secret/" + environments.DefaultConfigEndpointPath: "POST /{env}/{secret}/" + environments.DefaultConfigEndpointPath, + env + environments.DefaultFlagsPath: "POST /{env}/" + environments.DefaultFlagsPath, + } { + _, pattern := mux.Handler(httptest.NewRequest(http.MethodPost, path, nil)) + require.Equal(t, want, pattern, path) + } +} + +func TestRegisterRoutesWithoutExtensions(t *testing.T) { + params := &config.ServiceParameters{ + Osquery: &config.YAMLConfigurationOsquery{}, + Osctrld: &config.YAMLConfigurationOsctrld{Enabled: true}, + } + mux := http.NewServeMux() + registerRoutes(mux, &handlers.HandlersTLS{}, params, http.NotFoundHandler()) + _, pattern := mux.Handler(httptest.NewRequest(http.MethodPost, "/11111111-1111-4111-8111-111111111111/"+environments.DefaultExtensionsPath, nil)) + require.NotEqual(t, "POST /{env}/"+environments.DefaultExtensionsPath, pattern) +} diff --git a/deploy/cicd/nginx/frontend-tls.conf b/deploy/cicd/nginx/frontend-tls.conf index 757088066..9c2d1ea7d 100644 --- a/deploy/cicd/nginx/frontend-tls.conf +++ b/deploy/cicd/nginx/frontend-tls.conf @@ -95,7 +95,8 @@ server { add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; proxy_buffering off; - client_max_body_size 64m; + # 64 MiB extension uploads plus multipart framing. + client_max_body_size 65m; } location = /index.html { diff --git a/deploy/cicd/nginx/frontend.conf b/deploy/cicd/nginx/frontend.conf index 799d8836f..10a4bc3b4 100644 --- a/deploy/cicd/nginx/frontend.conf +++ b/deploy/cicd/nginx/frontend.conf @@ -69,7 +69,8 @@ server { add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; proxy_buffering off; - client_max_body_size 64m; + # 64 MiB extension uploads plus multipart framing. + client_max_body_size 65m; } location = /index.html { diff --git a/deploy/docker/conf/nginx/frontend-dev.conf b/deploy/docker/conf/nginx/frontend-dev.conf index a98cc0d2f..1107f81c5 100644 --- a/deploy/docker/conf/nginx/frontend-dev.conf +++ b/deploy/docker/conf/nginx/frontend-dev.conf @@ -73,7 +73,8 @@ server { add_header Content-Security-Policy "default-src 'self'; script-src 'self' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; proxy_buffering off; - client_max_body_size 64m; + # 64 MiB extension uploads plus multipart framing. + client_max_body_size 65m; } # index.html must never be cached — it's the entry point that maps to the diff --git a/deploy/nginx/frontend.conf b/deploy/nginx/frontend.conf index 138f82bce..d0ddb2fcd 100644 --- a/deploy/nginx/frontend.conf +++ b/deploy/nginx/frontend.conf @@ -124,7 +124,8 @@ server { # CSV exports, log streams, and carve archives can be large. proxy_buffering off; - client_max_body_size 64m; + # 64 MiB extension uploads plus multipart framing. + client_max_body_size 65m; } # SPA fallback — everything else returns index.html so TanStack Router diff --git a/frontend/src/api/extensions.ts b/frontend/src/api/extensions.ts new file mode 100644 index 000000000..0f71f0507 --- /dev/null +++ b/frontend/src/api/extensions.ts @@ -0,0 +1,96 @@ +/** + * Extensions API client: the osquery extension library and its per-platform/arch + * binaries (super admin only), plus environment and host assignments. + */ +import { apiFetch } from './client'; +import type { + EnvExtensions, + Extension, + ExtensionCreateRequest, + ExtensionUpdateRequest, + NodeExtensions, +} from './types'; + +const base = '/api/v1/extensions'; +const json = { 'Content-Type': 'application/json' }; + +/** Query key shared by the page and anything that must refresh the library. */ +export const extensionsQueryKey = ['extensions'] as const; + +export function listExtensions(): Promise { + return apiFetch(base); +} + +export function createExtension(body: ExtensionCreateRequest): Promise { + return apiFetch(base, { method: 'POST', headers: json, body: JSON.stringify(body) }); +} + +export function updateExtension(id: number, body: ExtensionUpdateRequest): Promise { + return apiFetch(`${base}/${id}`, { + method: 'PATCH', + headers: json, + body: JSON.stringify(body), + }); +} + +export function deleteExtension(id: number): Promise { + return apiFetch(`${base}/${id}`, { method: 'DELETE' }); +} + +/** PUT multipart (field `file`); the browser sets the multipart boundary. */ +export function uploadExtensionFile( + id: number, + platform: string, + arch: string, + file: File, +): Promise { + const body = new FormData(); + body.append('file', file); + return apiFetch(`${base}/${id}/files/${platform}/${arch}`, { method: 'PUT', body }); +} + +export function deleteExtensionFile(id: number, platform: string, arch: string): Promise { + return apiFetch(`${base}/${id}/files/${platform}/${arch}`, { method: 'DELETE' }); +} + +// --------------------------------------------------------------------------- +// Assignments +// --------------------------------------------------------------------------- + +const envPath = (env: string) => `/api/v1/environments/extensions/${encodeURIComponent(env)}`; +const nodePath = (env: string, uuid: string) => + `/api/v1/nodes/${encodeURIComponent(env)}/node/${encodeURIComponent(uuid)}/extensions`; + +export function getEnvExtensions(env: string): Promise { + return apiFetch(envPath(env)); +} + +export function assignEnvExtension(env: string, id: number): Promise { + return apiFetch(`${envPath(env)}/${id}`, { method: 'PUT' }); +} + +export function unassignEnvExtension(env: string, id: number): Promise { + return apiFetch(`${envPath(env)}/${id}`, { method: 'DELETE' }); +} + +export function getNodeExtensions(env: string, uuid: string): Promise { + return apiFetch(nodePath(env, uuid)); +} + +/** Super admin only. `expires_at` is required by the API: RFC3339, or null for no expiry. */ +export function assignNodeExtension( + env: string, + uuid: string, + id: number, + expires_at: string | null, +): Promise { + return apiFetch(`${nodePath(env, uuid)}/${id}`, { + method: 'PUT', + headers: json, + body: JSON.stringify({ expires_at }), + }); +} + +export function unassignNodeExtension(env: string, uuid: string, id: number): Promise { + return apiFetch(`${nodePath(env, uuid)}/${id}`, { method: 'DELETE' }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b0dda72e3..54af23948 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -693,3 +693,96 @@ export interface NodeFlagsRequest { /** RFC3339; null/omitted for no expiry. */ expires_at?: string | null; } + +// --------------------------------------------------------------------------- +// Extensions (osquery extension library; super admin only) +// --------------------------------------------------------------------------- + +/** One extension binary for a platform/arch (wire shape of ExtensionFileView). */ +export interface ExtensionFile { + platform: string; + arch: string; + size: number; + sha256: string; + filename: string; + updated_at: string; + updated_by: string; +} + +/** Wire shape of ExtensionView. `environments`/`nodes` count assignments. */ +export interface Extension { + id: number; + name: string; + description: string; + version: string; + command_execution: boolean; + files: ExtensionFile[]; + environments: number; + nodes: number; + created_by: string; + updated_at: string; +} + +export interface ExtensionCreateRequest { + name: string; + description: string; + version: string; + command_execution: boolean; +} + +export type ExtensionUpdateRequest = Partial>; + +/** Wire shape of ExtensionSummary: a library entry as offered for assignment. */ +export interface ExtensionSummary { + id: number; + name: string; + description: string; + version: string; + command_execution: boolean; + platforms: string[]; +} + +/** GET/PUT/DELETE /environments/extensions/{env}. `available` never has command-execution extensions. */ +export interface EnvExtensions { + assigned: ExtensionSummary[]; + available: ExtensionSummary[]; +} + +export interface NodeExtensionEffective { + name: string; + source: 'environment' | 'host'; + command_execution: boolean; + platforms: string[]; + /** Whether a file exists for the host's platform. */ + available: boolean; +} + +export interface NodeExtensionAssignment { + extension_id: number; + name: string; + expires_at: string | null; + expired: boolean; + created_by: string; + created_at: string; +} + +/** Last status osctrld reported for one extension. Every field but reported_at is host-reported (untrusted). */ +export interface NodeExtensionStatus { + name: string; + sha256: string; + /** ok or rejected for an install or update; removed or kept for a removal that worked or failed. */ + result: string; + error: string; + at: string | null; + reported_at: string; + /** Set by osctrl: a command-execution extension osctrld reported kept while the host has no unexpired assignment for it. */ + lingering?: boolean; +} + +export interface NodeExtensions { + effective: NodeExtensionEffective[] | null; + assignments: NodeExtensionAssignment[] | null; + status: NodeExtensionStatus[] | null; + /** Command-execution extensions that can be assigned; null unless the caller is a super admin. */ + library: ExtensionSummary[] | null; +} diff --git a/frontend/src/components/chrome/SideNav.test.tsx b/frontend/src/components/chrome/SideNav.test.tsx index 68f236024..af5832a50 100644 --- a/frontend/src/components/chrome/SideNav.test.tsx +++ b/frontend/src/components/chrome/SideNav.test.tsx @@ -77,3 +77,25 @@ describe('SideNav vulnerabilities entry', () => { expect(screen.queryByRole('link', { name: 'Vulnerabilities' })).not.toBeInTheDocument(); }); }); + +describe('SideNav extensions entry', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('is shown to super admins', async () => { + mockGetFeatures.mockResolvedValue(baseFeatures); + mockGetMe.mockResolvedValue({ admin: true, permissions: {} }); + renderNav(); + const link = await screen.findByRole('link', { name: 'Extensions' }); + expect(link).toHaveAttribute('href', '/_app/extensions'); + }); + + it('is hidden from non-admins', async () => { + mockGetFeatures.mockResolvedValue(baseFeatures); + mockGetMe.mockResolvedValue({ admin: false, permissions: { 'env-uuid': { user: true, query: false, carve: false, admin: false } } }); + renderNav(); + await settle(); + expect(screen.queryByRole('link', { name: 'Extensions' })).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index ec4785b26..8d1e41b7e 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -9,6 +9,7 @@ import { DatabaseBackup, Download, FileSearch, + Puzzle, FileStack, HeartPulse, LayoutDashboard, @@ -257,6 +258,8 @@ export function SideNav({ className, collapsed, previewsEnabled = true }: SideNa pathname.startsWith('/_app/config') || pathname.startsWith('/config'); const isLogSinksActive = pathname.startsWith('/_app/log-sinks') || pathname.startsWith('/log-sinks'); + const isExtensionsActive = + pathname.startsWith('/_app/extensions') || pathname.startsWith('/extensions'); const isAlertsActive = pathname.startsWith('/_app/alerts') || pathname.startsWith('/alerts'); const isHealth = pathname.startsWith('/_app/health') || pathname.startsWith('/health'); @@ -588,6 +591,15 @@ export function SideNav({ className, collapsed, previewsEnabled = true }: SideNa > {t('nav.logSinks')} } + } + > + {t('nav.extensions')} + {features?.alerts && ({ mockGetEnvironment: vi.fn<() => Promise>(), mockGetConfig: vi.fn<() => Promise>(), @@ -42,6 +43,7 @@ const { mockSetInactiveHours: vi.fn(), mockResetInactiveHours: vi.fn(), mockGetMe: vi.fn(), + mockGetEnvExtensions: vi.fn(), })); vi.mock('$/api/environments', () => ({ @@ -56,6 +58,10 @@ vi.mock('$/api/environments', () => ({ patchEnvironmentExpiration: (...args: unknown[]) => mockPatchExpiration(...args), })); +vi.mock('$/api/extensions', () => ({ + getEnvExtensions: (...args: unknown[]) => mockGetEnvExtensions(...args), +})); + vi.mock('$/api/users', () => ({ getMe: mockGetMe })); vi.mock('$/api/client', () => ({ @@ -529,6 +535,23 @@ describe('EnvConfigPage', () => { expect(await screen.findByRole('tab', { name: 'Posture' })).toBeInTheDocument(); }); + it('has an Extensions tab that loads the environment assignments only when opened', async () => { + mockGetEnvExtensions.mockResolvedValue({ assigned: [], available: [] }); + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('tab', { name: 'Extensions' })); + + expect(await screen.findByText('No extensions assigned to this environment.')).toBeInTheDocument(); + expect(mockGetEnvExtensions).toHaveBeenCalledWith('dev'); + }); + + it('does not load extensions before the tab is opened', async () => { + renderWithProviders(); + await screen.findByRole('tab', { name: 'Extensions' }); + expect(mockGetEnvExtensions).not.toHaveBeenCalled(); + }); + it('hides the posture tab when posture is disabled', async () => { renderWithProviders(); diff --git a/frontend/src/features/environments/EnvConfigPage.tsx b/frontend/src/features/environments/EnvConfigPage.tsx index 280b33c26..3dd9e8ac1 100644 --- a/frontend/src/features/environments/EnvConfigPage.tsx +++ b/frontend/src/features/environments/EnvConfigPage.tsx @@ -18,6 +18,7 @@ import { AuthError, ApiError } from '$/api/client'; import { getPostureProfiles } from '$/api/nodes'; import { getFeatures } from '$/api/features'; import type { PostureProfile } from '$/api/types'; +import { EnvExtensionsTab } from './EnvExtensionsTab'; import { applyPostureProfileToSchedule } from './postureSchedule'; import { VulnProfilePicker } from '$/features/vulnerabilities/VulnProfilePicker'; import { cn } from '$/lib/cn'; @@ -138,7 +139,7 @@ export function EnvConfigPage() { const [postureAggressiveness, setPostureAggressiveness] = useState(1); const [showVulnPicker, setShowVulnPicker] = useState(false); const closeVulnPicker = useCallback(() => setShowVulnPicker(false), []); - type TabKey = 'settings' | SectionKey | 'posture' | 'assembled'; + type TabKey = 'settings' | SectionKey | 'posture' | 'extensions' | 'assembled'; const [activeTab, setActiveTab] = useState('settings'); const featuresQuery = useQuery({ queryKey: ['features'], @@ -383,6 +384,12 @@ export function EnvConfigPage() { onClick={() => setActiveTab('posture')} /> )} + setActiveTab('extensions')} + /> )} + {activeTab === 'extensions' && } + {activeTab === 'posture' && postureEnabled && ( ({ + getEnvExtensions: vi.fn(), + assignEnvExtension: vi.fn(), + unassignEnvExtension: vi.fn(), +})); + +vi.mock('$/api/extensions', () => ({ + getEnvExtensions: api.getEnvExtensions, + assignEnvExtension: api.assignEnvExtension, + unassignEnvExtension: api.unassignEnvExtension, +})); + +function ext(o: Partial = {}): ExtensionSummary { + return { + id: 1, + name: 'osq-ext', + description: '', + version: '1.0.0', + command_execution: false, + platforms: ['darwin', 'linux'], + ...o, + }; +} + +function renderTab() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render( + + + , + ); + return { qc, ...view }; +} + +describe('EnvExtensionsTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + api.getEnvExtensions.mockResolvedValue({ + assigned: [ext()], + available: [ext({ id: 2, name: 'other-ext', version: '2.0.0', platforms: ['windows'] })], + } satisfies EnvExtensions); + }); + + it('lists assigned extensions with version and platforms, and the sync note', async () => { + renderTab(); + const row = (await screen.findByText('osq-ext')).closest('li') as HTMLElement; + expect(within(row).getByText('1.0.0')).toBeInTheDocument(); + expect(within(row).getByText('darwin, linux')).toBeInTheDocument(); + expect(api.getEnvExtensions).toHaveBeenCalledWith('prod'); + expect( + screen.getByText('Hosts get these extensions from osctrld on its next sync (up to an hour).'), + ).toBeInTheDocument(); + }); + + it('assigns the picked extension and shows the refreshed view', async () => { + api.assignEnvExtension.mockResolvedValue({ + assigned: [ext(), ext({ id: 2, name: 'other-ext' })], + available: [], + }); + const user = userEvent.setup(); + const { qc } = renderTab(); + await screen.findByText('osq-ext'); + const add = screen.getByRole('button', { name: 'Add extension' }); + expect(add).toBeDisabled(); + const picker = screen.getByLabelText('Extension to add'); + // Only the library's non-command-execution extensions the API offers are listed. + expect(within(picker).getAllByRole('option').map((o) => o.textContent)).toEqual(['Select an extension…', 'other-ext 2.0.0']); + await user.selectOptions(picker, 'other-ext 2.0.0'); + await user.click(add); + await waitFor(() => expect(api.assignEnvExtension).toHaveBeenCalledWith('prod', 2)); + expect(await screen.findByText('other-ext')).toBeInTheDocument(); + expect(qc.getQueryData(['env-extensions', 'prod'])).toEqual({ + assigned: [ext(), ext({ id: 2, name: 'other-ext' })], + available: [], + }); + expect(screen.queryByLabelText('Extension to add')).not.toBeInTheDocument(); + }); + + it('removes an assigned extension', async () => { + api.unassignEnvExtension.mockResolvedValue({ assigned: [], available: [ext()] }); + const user = userEvent.setup(); + renderTab(); + await user.click(await screen.findByRole('button', { name: 'Remove osq-ext' })); + await waitFor(() => expect(api.unassignEnvExtension).toHaveBeenCalledWith('prod', 1)); + expect(await screen.findByText('No extensions assigned to this environment.')).toBeInTheDocument(); + }); + + it('shows the server error inline', async () => { + api.assignEnvExtension.mockRejectedValue(new Error('extension is not assignable')); + const user = userEvent.setup(); + renderTab(); + await screen.findByText('osq-ext'); + await user.selectOptions(screen.getByLabelText('Extension to add'), 'other-ext 2.0.0'); + await user.click(screen.getByRole('button', { name: 'Add extension' })); + expect(await screen.findByRole('alert')).toHaveTextContent('extension is not assignable'); + }); +}); diff --git a/frontend/src/features/environments/EnvExtensionsTab.tsx b/frontend/src/features/environments/EnvExtensionsTab.tsx new file mode 100644 index 000000000..6596e84cd --- /dev/null +++ b/frontend/src/features/environments/EnvExtensionsTab.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { assignEnvExtension, getEnvExtensions, unassignEnvExtension } from '$/api/extensions'; +import type { EnvExtensions } from '$/api/types'; +import { buttonClasses } from '$/components/atoms/Button'; + +/** Ordinary (non command-execution) extensions assigned to every host in an environment. */ +export function EnvExtensionsTab({ env }: { env: string }) { + const qc = useQueryClient(); + const queryKey = ['env-extensions', env]; + const { data, isLoading, error } = useQuery({ + queryKey, + queryFn: () => getEnvExtensions(env), + staleTime: 10_000, + }); + const [pick, setPick] = useState(''); + const [actionError, setActionError] = useState(null); + + const done = (resp: EnvExtensions) => { + qc.setQueryData(queryKey, resp); + setPick(''); + setActionError(null); + }; + const assign = useMutation({ + mutationFn: (id: number) => assignEnvExtension(env, id), + onSuccess: done, + onError: (e: Error) => setActionError(e.message), + }); + const unassign = useMutation({ + mutationFn: (id: number) => unassignEnvExtension(env, id), + onSuccess: done, + onError: (e: Error) => setActionError(e.message), + }); + + if (isLoading) return

Loading extensions…

; + if (error || !data) { + return ( +

+ {error instanceof Error ? error.message : 'Failed to load extensions'} +

+ ); + } + + const assigned = data.assigned ?? []; + const available = data.available ?? []; + const busy = assign.isPending || unassign.isPending; + + return ( +
+

+ Hosts get these extensions from osctrld on its next sync (up to an hour). +

+ {assigned.length === 0 ? ( +

No extensions assigned to this environment.

+ ) : ( +
    + {assigned.map((x) => ( +
  • + {x.name} + {x.version} + {x.platforms.join(', ')} + +
  • + ))} +
+ )} + {actionError && ( +

+ {actionError} +

+ )} + {available.length > 0 && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/features/extensions/ExtensionsPage.test.tsx b/frontend/src/features/extensions/ExtensionsPage.test.tsx new file mode 100644 index 000000000..d9168c1bf --- /dev/null +++ b/frontend/src/features/extensions/ExtensionsPage.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { ExtensionsPage } from './ExtensionsPage'; +import type { Extension } from '$/api/types'; + +const mockList = vi.fn<() => Promise>(); +const mockCreate = vi.fn(); +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); +const mockUpload = vi.fn(); +const mockDeleteFile = vi.fn(); + +vi.mock('$/api/extensions', () => ({ + extensionsQueryKey: ['extensions'], + listExtensions: () => mockList(), + createExtension: (...a: unknown[]) => mockCreate(...a), + updateExtension: (...a: unknown[]) => mockUpdate(...a), + deleteExtension: (...a: unknown[]) => mockDelete(...a), + uploadExtensionFile: (...a: unknown[]) => mockUpload(...a), + deleteExtensionFile: (...a: unknown[]) => mockDeleteFile(...a), +})); + +function ext(o: Partial = {}): Extension { + return { + id: 1, + name: 'osquery-ext', + description: 'does things', + version: '1.2.3', + command_execution: false, + files: [], + environments: 0, + nodes: 0, + created_by: 'admin', + updated_at: new Date().toISOString(), + ...o, + }; +} + +function renderPage() { + const rootRoute = createRootRoute({ component: () => }); + const pageRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: ExtensionsPage }); + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockList.mockResolvedValue([]); +}); + +describe('ExtensionsPage', () => { + it('renders extensions with the command execution badge and files', async () => { + mockList.mockResolvedValue([ + ext({ + command_execution: true, + environments: 2, + nodes: 5, + files: [ + { + platform: 'linux', + arch: 'amd64', + size: 2048, + sha256: 'abcdef0123456789', + filename: 'ext.ext', + updated_at: new Date().toISOString(), + updated_by: 'admin', + }, + ], + }), + ]); + renderPage(); + expect(await screen.findByText('osquery-ext')).toBeInTheDocument(); + expect(screen.getByText('Command execution')).toBeInTheDocument(); + expect(screen.getByText('abcdef01')).toBeInTheDocument(); + expect(screen.getByText('2.0 KB')).toBeInTheDocument(); + expect(screen.getByText(/2 environments/)).toBeInTheDocument(); + expect(screen.getByText(/5 hosts/)).toBeInTheDocument(); + }); + + it('creates an extension with the typed body', async () => { + mockCreate.mockResolvedValue(ext()); + const user = userEvent.setup(); + renderPage(); + await user.click(await screen.findByRole('button', { name: 'New extension' })); + await user.type(screen.getByLabelText('Name'), 'my-ext'); + await user.type(screen.getByLabelText('Description'), 'desc'); + await user.type(screen.getByLabelText('Version'), '0.1.0'); + await user.click(screen.getByLabelText('Can execute commands')); + await user.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(mockCreate).toHaveBeenCalledWith({ + name: 'my-ext', + description: 'desc', + version: '0.1.0', + command_execution: true, + }), + ); + }); + + it('uploads a file as the right platform/arch', async () => { + mockList.mockResolvedValue([ext({ id: 7 })]); + mockUpload.mockResolvedValue(ext({ id: 7 })); + const user = userEvent.setup(); + renderPage(); + const input = await screen.findByLabelText('Upload linux/amd64 for osquery-ext'); + const file = new File(['bin'], 'ext.ext'); + await user.upload(input, file); + await waitFor(() => expect(mockUpload).toHaveBeenCalledWith(7, 'linux', 'amd64', file)); + }); + + it('refuses files over 64 MB client-side', async () => { + mockList.mockResolvedValue([ext({ id: 7 })]); + const user = userEvent.setup(); + renderPage(); + const input = await screen.findByLabelText('Upload linux/amd64 for osquery-ext'); + const big = new File(['x'], 'big.ext'); + Object.defineProperty(big, 'size', { value: 64 * 1024 * 1024 + 1 }); + await user.upload(input, big); + expect(await screen.findByText('Files can be at most 64 MB.')).toBeInTheDocument(); + expect(mockUpload).not.toHaveBeenCalled(); + }); + + it('disables extension delete while assigned, with a tooltip', async () => { + mockList.mockResolvedValue([ext({ environments: 1 }), ext({ id: 2, name: 'free-ext' })]); + renderPage(); + await screen.findByText('osquery-ext'); + const assigned = screen.getByRole('button', { name: 'Delete osquery-ext' }); + expect(assigned).toBeDisabled(); + expect(assigned.closest('span[title]')).toHaveAttribute( + 'title', + 'Remove it from all environments and hosts first', + ); + expect(screen.getByRole('button', { name: 'Delete free-ext' })).toBeEnabled(); + }); + + it('shows server errors inline', async () => { + mockList.mockResolvedValue([ext({ id: 7 })]); + mockUpload.mockRejectedValue(new Error('empty file')); + const user = userEvent.setup(); + renderPage(); + const input = await screen.findByLabelText('Upload linux/amd64 for osquery-ext'); + await user.upload(input, new File(['x'], 'a.ext')); + const alert = await screen.findByRole('alert'); + expect(within(alert).getByText('empty file')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/extensions/ExtensionsPage.tsx b/frontend/src/features/extensions/ExtensionsPage.tsx new file mode 100644 index 000000000..010bc3b06 --- /dev/null +++ b/frontend/src/features/extensions/ExtensionsPage.tsx @@ -0,0 +1,368 @@ +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Copy, Pencil, Plus, Trash2, Upload } from 'lucide-react'; +import { + createExtension, + deleteExtension, + deleteExtensionFile, + extensionsQueryKey, + listExtensions, + updateExtension, + uploadExtensionFile, +} from '$/api/extensions'; +import type { Extension } from '$/api/types'; +import { usePageTitle } from '$/lib/usePageTitle'; +import { formatBytes } from '$/lib/time'; +import { buttonClasses } from '$/components/atoms/Button'; +import { ModalShell } from '$/components/feedback/ModalShell'; +import { EmptyState } from '$/components/data/EmptyState'; +import { MetadataBadge } from '$/components/data/MetadataBadge'; +import { SkeletonRow } from '$/components/data/Skeleton'; + +const MAX_FILE_BYTES = 64 * 1024 * 1024; +const ASSIGNED_TIP = 'Remove it from all environments and hosts first'; + +// Platform/arch pairs the server accepts for extension binaries. +const SLOTS: ReadonlyArray<[platform: string, arch: string]> = [ + ['darwin', 'amd64'], + ['darwin', 'arm64'], + ['darwin', 'universal'], + ['linux', 'amd64'], + ['linux', 'arm64'], + ['windows', 'amd64'], +]; + +const inputClass = + 'w-full h-8 px-2.5 text-sm rounded-md bg-[color:var(--bg-1)] text-[color:var(--text-1)] ' + + 'border border-[color:var(--border-strong)] outline-none focus:border-[color:var(--accent)]'; + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function ExtensionsPage() { + const { t } = useTranslation(); + usePageTitle(t('pageTitle.extensions')); + const qc = useQueryClient(); + const [error, setError] = useState(null); + const [uploading, setUploading] = useState(null); + const [editor, setEditor] = useState<{ ext?: Extension } | null>(null); + + const { data, isLoading, error: loadError } = useQuery({ + queryKey: extensionsQueryKey, + queryFn: listExtensions, + }); + + const refresh = () => qc.invalidateQueries({ queryKey: extensionsQueryKey }); + const onError = (e: unknown) => setError(errMsg(e)); + + const removeExt = useMutation({ + mutationFn: (id: number) => deleteExtension(id), + onSuccess: () => { + setError(null); + void refresh(); + }, + onError, + }); + const removeFile = useMutation({ + mutationFn: (v: { id: number; platform: string; arch: string }) => + deleteExtensionFile(v.id, v.platform, v.arch), + onSuccess: () => { + setError(null); + void refresh(); + }, + onError, + }); + const upload = useMutation({ + mutationFn: (v: { id: number; platform: string; arch: string; file: File }) => + uploadExtensionFile(v.id, v.platform, v.arch, v.file), + onSuccess: () => { + setError(null); + void refresh(); + }, + onError, + onSettled: () => setUploading(null), + }); + + function pickFile(ext: Extension, platform: string, arch: string, file: File | undefined) { + if (!file) return; + if (file.size > MAX_FILE_BYTES) { + setError('Files can be at most 64 MB.'); + return; + } + setError(null); + setUploading(`${ext.id}/${platform}/${arch}`); + upload.mutate({ id: ext.id, platform, arch, file }); + } + + const rows = data ?? []; + + return ( +
+
+

+ {t('pageTitle.extensions')} +

+

+ osquery extension library. Upload one binary per platform, then assign it to environments + or hosts. +

+ +
+ + {(error || loadError) && ( +
+ {error ?? errMsg(loadError)} +
+ )} + +
+ {isLoading ? ( + + + + + +
+ ) : rows.length === 0 && !loadError ? ( + + ) : ( + + + + + + + + + + + {rows.map((ext) => { + const assigned = ext.environments > 0 || ext.nodes > 0; + return ( + + + + + + + + ); + })} + +
NameVersionFilesAssigned to +
+
{ext.name}
+ {ext.description && ( +
{ext.description}
+ )} +
+ {ext.version}{' '} + {ext.command_execution && Command execution} + +
    + {SLOTS.map(([platform, arch]) => { + const f = ext.files?.find((x) => x.platform === platform && x.arch === arch); + const key = `${ext.id}/${platform}/${arch}`; + const slot = `${platform}/${arch}`; + return ( +
  • + {slot} + {f ? ( + <> + {formatBytes(f.size)} + + {f.sha256.slice(0, 8)} + + + + ) : ( + no file + )} + {uploading === key ? ( + Uploading… + ) : ( + + )} + {f && ( + + )} +
  • + ); + })} +
+
+ {ext.environments} {ext.environments === 1 ? 'environment' : 'environments'} + {', '} + {ext.nodes} {ext.nodes === 1 ? 'host' : 'hosts'} + +
+ + {/* A disabled button swallows hover, so the tooltip lives on the wrapper. */} + + + +
+
+ )} +
+ + {editor && ( + setEditor(null)} + onSaved={() => { + setEditor(null); + void refresh(); + }} + /> + )} +
+ ); +} + +function ExtensionDialog({ + ext, + onClose, + onSaved, +}: { + ext?: Extension; + onClose: () => void; + onSaved: () => void; +}) { + const [name, setName] = useState(ext?.name ?? ''); + const [description, setDescription] = useState(ext?.description ?? ''); + const [version, setVersion] = useState(ext?.version ?? ''); + const [exec, setExec] = useState(ext?.command_execution ?? false); + const [error, setError] = useState(null); + + const save = useMutation({ + mutationFn: () => + ext + ? updateExtension(ext.id, { description, version, command_execution: exec }) + : createExtension({ name, description, version, command_execution: exec }), + onSuccess: onSaved, + onError: (e) => setError(errMsg(e)), + }); + + function submit(e: FormEvent) { + e.preventDefault(); + setError(null); + save.mutate(); + } + + const label = 'block text-xs font-semibold text-[color:var(--text-2)] mb-1'; + return ( + +
+
+ + setName(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} /> +
+
+ + setVersion(e.target.value)} /> +
+ + {error && ( +

+ {error} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/nodes/NodeConfigTab.test.tsx b/frontend/src/features/nodes/NodeConfigTab.test.tsx index b95b2ec6c..db12ce638 100644 --- a/frontend/src/features/nodes/NodeConfigTab.test.tsx +++ b/frontend/src/features/nodes/NodeConfigTab.test.tsx @@ -10,6 +10,7 @@ const api = vi.hoisted(() => ({ putNodeConfig: vi.fn(), deleteNodeConfig: vi.fn(), getNodeFlags: vi.fn(), + getNodeExtensions: vi.fn(), })); vi.mock('$/api/nodes', () => ({ @@ -19,6 +20,10 @@ vi.mock('$/api/nodes', () => ({ getNodeFlags: api.getNodeFlags, })); +vi.mock('$/api/extensions', () => ({ + getNodeExtensions: api.getNodeExtensions, +})); + // Monaco pulls in a heavy worker — stub it with a textarea. vi.mock('$/components/forms/CodeEditor', () => ({ CodeEditor: ({ @@ -242,4 +247,16 @@ describe('NodeConfigTab', () => { await user.click(screen.getByRole('button', { name: 'Configuration' })); expect(await screen.findByLabelText('Applied configuration')).toBeInTheDocument(); }); + + it('has an Extensions view alongside Configuration and Flags', async () => { + api.getNodeExtensions.mockResolvedValue({ effective: [], assignments: [], status: [], library: null }); + const user = userEvent.setup(); + renderTab(); + await screen.findByLabelText('Applied configuration'); + expect(api.getNodeExtensions).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Extensions' })); + expect(screen.getByRole('button', { name: 'Extensions' })).toHaveAttribute('aria-pressed', 'true'); + expect(await screen.findByText('No extensions apply to this host.')).toBeInTheDocument(); + expect(api.getNodeExtensions).toHaveBeenCalledWith('prod', 'uuid-1'); + }); }); diff --git a/frontend/src/features/nodes/NodeConfigTab.tsx b/frontend/src/features/nodes/NodeConfigTab.tsx index 0ccde9e61..21f0a99fa 100644 --- a/frontend/src/features/nodes/NodeConfigTab.tsx +++ b/frontend/src/features/nodes/NodeConfigTab.tsx @@ -6,6 +6,7 @@ import { buttonClasses } from '$/components/atoms/Button'; import { ModalShell } from '$/components/feedback/ModalShell'; import { CodeEditor } from '$/components/forms/CodeEditor'; import { DiffView } from '$/components/forms/DiffView'; +import { NodeExtensionsView } from './NodeExtensionsView'; import { NodeFlagsView } from './NodeFlagsView'; import { plainBtn, toLocalInput, VIEWS, warnBtn, type View } from './overrideShared'; @@ -258,30 +259,38 @@ function ConfigView({ env, uuid, hostname }: { env: string; uuid: string; hostna ); } -type Kind = 'configuration' | 'flags'; +type Kind = 'configuration' | 'flags' | 'extensions'; -/** Per-node overrides: osquery configuration (JSON) or startup flags (flagfile). */ +const KINDS: { id: Kind; label: string }[] = [ + { id: 'configuration', label: 'Configuration' }, + { id: 'flags', label: 'Flags' }, + { id: 'extensions', label: 'Extensions' }, +]; + +/** Per-node overrides: osquery configuration (JSON), startup flags (flagfile) or extensions. */ export function NodeConfigTab({ env, uuid, hostname }: { env: string; uuid: string; hostname: string }) { const [kind, setKind] = useState('configuration'); return (
- {(['configuration', 'flags'] as const).map((k) => ( + {KINDS.map(({ id, label }) => ( ))}
{kind === 'configuration' ? ( - ) : ( + ) : kind === 'flags' ? ( + ) : ( + )}
); diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index 8ffb80f4d..20ddc4895 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -11,7 +11,7 @@ import { Outlet, } from '@tanstack/react-router'; import { NodeDetailPage } from './NodeDetailPage'; -import type { NodeConfig, NodeFlags, NodePosture, OsqueryNode, SavedQueriesPagedResponse, AdminTag, PostureScore } from '$/api/types'; +import type { NodeConfig, NodeExtensions, NodeFlags, NodePosture, OsqueryNode, SavedQueriesPagedResponse, AdminTag, PostureScore } from '$/api/types'; import type { NodeActivityBucket, NodeTileSeries } from '$/api/stats'; import type { Features } from '$/api/features'; @@ -20,6 +20,7 @@ const mockListNodeLogs = vi.fn<() => Promise>(); const mockDeleteNode = vi.fn<() => Promise<{ message: string }>>(); const mockGetNodeConfig = vi.fn<() => Promise>(); const mockGetNodeFlags = vi.fn<() => Promise>(); +const mockGetNodeExtensions = vi.fn<() => Promise>(); const mockGetNodePosture = vi.fn<() => Promise>(); const mockGetNodePostureScore = vi.fn<() => Promise>(); const mockGetMe = vi.fn<() => Promise>(); @@ -44,6 +45,10 @@ vi.mock('$/api/nodes', () => ({ getNodePostureScore: (...args: unknown[]) => mockGetNodePostureScore(...(args as [])), })); +vi.mock('$/api/extensions', () => ({ + getNodeExtensions: (...args: unknown[]) => mockGetNodeExtensions(...(args as [])), +})); + vi.mock('$/api/users', () => ({ getMe: (...args: unknown[]) => mockGetMe(...(args as [])), })); @@ -187,6 +192,26 @@ function makeNodeFlags(overrides: Partial = {}): NodeFlags { }; } +function makeNodeExtensions(overrides: Partial = {}): NodeExtensions { + return { effective: [], assignments: [], status: [], library: null, ...overrides }; +} + +function hostAssignment(o: Partial[number]> = {}) { + return { + extension_id: 9, + name: 'cmd-ext', + expires_at: null, + expired: false, + created_by: 'root', + created_at: '2026-10-03T10:00:00Z', + ...o, + }; +} + +function extStatus(result: string, o: Partial[number]> = {}) { + return { name: 'cmd-ext', sha256: 'bb', result, error: '', at: null, reported_at: '2026-10-03T11:00:00Z', ...o }; +} + function makeActivityBuckets(): NodeActivityBucket[] { return [ { @@ -333,6 +358,7 @@ describe('NodeDetailPage', () => { mockGetNodePosture.mockResolvedValue([]); mockGetNodeConfig.mockResolvedValue(makeNodeConfig()); mockGetNodeFlags.mockResolvedValue(makeNodeFlags()); + mockGetNodeExtensions.mockResolvedValue(makeNodeExtensions()); mockGetMe.mockResolvedValue({ admin: true, permissions: {} }); mockListAlertRules.mockResolvedValue([]); mockListEnvironments.mockResolvedValue([{ id: 5, name: 'test-env', uuid: 'env-uuid-1' }]); @@ -1227,6 +1253,7 @@ describe('NodeDetailPage', () => { expect(screen.queryByRole('tab', { name: /Configuration/ })).not.toBeInTheDocument(); expect(mockGetNodeConfig).not.toHaveBeenCalled(); expect(mockGetNodeFlags).not.toHaveBeenCalled(); + expect(mockGetNodeExtensions).not.toHaveBeenCalled(); }); it('shows the banner and tab dot for a flags-only override', async () => { @@ -1270,4 +1297,113 @@ describe('NodeDetailPage', () => { await screen.findByRole('heading', { name: 'web-server-01' }); expect(screen.queryByText(/has a flags override/)).not.toBeInTheDocument(); }); + + describe('command execution banner', () => { + const allowed = 'Command execution is allowed on this host via cmd-ext, set by root on 2026-10-03.'; + + it('says allowed only when the last status is ok', async () => { + mockGetNodeExtensions.mockResolvedValue( + makeNodeExtensions({ assignments: [hostAssignment()], status: [extStatus('ok')] }), + ); + renderWithProviders(makeTestRouter()); + expect(await screen.findByText(allowed)).toBeInTheDocument(); + expect(screen.queryByText(/is not running/)).not.toBeInTheDocument(); + expect(within(screen.getByRole('tab', { name: /Configuration/ })).getByTestId('config-override-dot')).toBeInTheDocument(); + }); + + it('adds the expiry to the allowed wording', async () => { + mockGetNodeExtensions.mockResolvedValue( + makeNodeExtensions({ + assignments: [hostAssignment({ expires_at: '2099-01-02T10:00:00Z' })], + status: [extStatus('ok')], + }), + ); + renderWithProviders(makeTestRouter()); + expect(await screen.findByText(`${allowed} It expires 2099-01-02.`)).toBeInTheDocument(); + }); + + it('uses the expired wording for an expired assignment whose status is still ok', async () => { + mockGetNodeExtensions.mockResolvedValue( + makeNodeExtensions({ + assignments: [hostAssignment({ expires_at: '2026-10-05T10:00:00Z', expired: true })], + status: [extStatus('ok')], + }), + ); + renderWithProviders(makeTestRouter()); + expect( + await screen.findByText('Command execution via cmd-ext expired on 2026-10-05 and is no longer applied.'), + ).toBeInTheDocument(); + expect(screen.queryByText(/is allowed on this host/)).not.toBeInTheDocument(); + }); + + it('does not claim it runs when the ok status says extensions are disabled', async () => { + mockGetNodeExtensions.mockResolvedValue( + makeNodeExtensions({ + assignments: [hostAssignment()], + status: [extStatus('ok', { error: 'extensions are disabled by flags' })], + }), + ); + renderWithProviders(makeTestRouter()); + expect( + await screen.findByText('Command execution was assigned to this host via cmd-ext but is not running.'), + ).toBeInTheDocument(); + expect(screen.queryByText(/is allowed on this host/)).not.toBeInTheDocument(); + }); + + it('warns when a removed extension is still on the host, error as plain text', async () => { + mockGetNodeExtensions.mockResolvedValue( + makeNodeExtensions({ status: [extStatus('kept', { error: 'still here', lingering: true })] }), + ); + renderWithProviders(makeTestRouter()); + const banner = await screen.findByText( + 'Command execution via cmd-ext was removed, but osctrld could not remove it from this host: still here', + ); + expect(banner).toHaveAttribute('role', 'alert'); + expect(banner.querySelector('b')).toBeNull(); + expect(within(screen.getByRole('tab', { name: /Configuration/ })).getByTestId('config-override-dot')).toBeInTheDocument(); + }); + + it('shows the lingering warning, not the expired wording, for an expired assignment osctrld kept', async () => { + mockGetNodeExtensions.mockResolvedValue( + makeNodeExtensions({ + assignments: [hostAssignment({ expires_at: '2026-10-05T10:00:00Z', expired: true })], + status: [extStatus('kept', { error: 'cannot move', lingering: true })], + }), + ); + renderWithProviders(makeTestRouter()); + expect( + await screen.findByText('Command execution via cmd-ext was removed, but osctrld could not remove it from this host: cannot move'), + ).toBeInTheDocument(); + expect(screen.queryByText(/expired on .* and is no longer applied/)).not.toBeInTheDocument(); + }); + + it('does not warn about a stale ok reported after the removal', async () => { + mockGetNodeExtensions.mockResolvedValue(makeNodeExtensions({ status: [extStatus('ok')] })); + renderWithProviders(makeTestRouter()); + await waitFor(() => expect(mockGetNodeExtensions).toHaveBeenCalled()); + await screen.findByRole('heading', { name: 'web-server-01' }); + expect(screen.queryByText(/Command execution/)).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it.each([ + ['rejected status', [extStatus('rejected')]], + ['no status yet', []], + ])('says it is not running with %s', async (_n, status) => { + mockGetNodeExtensions.mockResolvedValue(makeNodeExtensions({ assignments: [hostAssignment()], status })); + renderWithProviders(makeTestRouter()); + expect( + await screen.findByText('Command execution was assigned to this host via cmd-ext but is not running.'), + ).toBeInTheDocument(); + expect(screen.queryByText(/is allowed on this host/)).not.toBeInTheDocument(); + }); + + it('shows no banner or dot without a host assignment', async () => { + renderWithProviders(makeTestRouter()); + await waitFor(() => expect(mockGetNodeExtensions).toHaveBeenCalled()); + await screen.findByRole('heading', { name: 'web-server-01' }); + expect(screen.queryByText(/Command execution/)).not.toBeInTheDocument(); + expect(screen.queryByTestId('config-override-dot')).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/features/nodes/NodeDetailPage.tsx b/frontend/src/features/nodes/NodeDetailPage.tsx index faddbd6af..e67bdb4c3 100644 --- a/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.tsx @@ -46,6 +46,7 @@ import { SearchInput } from '$/components/data/SearchInput'; import { ModalShell } from '$/components/feedback/ModalShell'; import { HealthBadge, TagChips } from './nodeSignals'; import { NodeConfigTab } from './NodeConfigTab'; +import { getNodeExtensions } from '$/api/extensions'; import { NodeFileExplorerTab } from './NodeFileExplorerTab'; import { NodeVulnerabilitiesTab } from '$/features/vulnerabilities/NodeVulnerabilitiesTab'; import { recomputePostureScore, controlKey } from './postureScore'; @@ -743,6 +744,18 @@ export function NodeDetailPage() { enabled: canAdminNode, staleTime: 10_000, }); + // Same key as NodeExtensionsView. Only command-execution assignments on this + // host drive the banner; the endpoint is admin-only like the other overrides. + const { data: nodeExtensions } = useQuery({ + queryKey: ['node-extensions', env, uuid], + queryFn: () => getNodeExtensions(env, uuid), + enabled: canAdminNode, + staleTime: 10_000, + }); + const hostExtensions = nodeExtensions?.assignments ?? []; + // Command-execution extensions revoked (removed or expired) that osctrld + // reported it could not remove from this host. + const lingeringExtensions = (nodeExtensions?.status ?? []).filter((st) => st.lingering); const hasConfigOverride = nodeConfig !== undefined && nodeConfig.override !== ''; const hasFlagsOverride = nodeFlags !== undefined && nodeFlags.override !== ''; @@ -1170,6 +1183,39 @@ export function NodeDetailPage() { )} + {lingeringExtensions.map((st) => ( + // The error comes from the host: plain text only. +
+ {`Command execution via ${st.name} was removed, but osctrld could not remove it from this host` + + (st.error ? `: ${st.error}` : '.')} +
+ ))} + + {hostExtensions.filter((a) => !lingeringExtensions.some((st) => st.name === a.name)).map((a) => { + // The lingering warning above takes precedence. Only an `ok` status without a note (e.g. extensions disabled by + // flags) proves the extension is running. + const running = + nodeExtensions?.status?.some((st) => st.name === a.name && st.result === 'ok' && !st.error) === true; + return ( +
+ {a.expired && a.expires_at + ? `Command execution via ${a.name} expired on ${isoDate(a.expires_at)} and is no longer applied.` + : running + ? `Command execution is allowed on this host via ${a.name}, set by ${a.created_by} on ${isoDate(a.created_at)}.` + + (a.expires_at ? ` It expires ${isoDate(a.expires_at)}.` : '') + : `Command execution was assigned to this host via ${a.name} but is not running.`} +
+ ); + })} + {/* ── Tabs ── */}