From 66c8bd5eff829a2caf96901108838a61f071f17b Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:31:37 +0200 Subject: [PATCH 1/2] Serve the osquery package digest and honor the configured version in verify --- cmd/tls/handlers/handlers.go | 25 ++++++++ cmd/tls/handlers/post.go | 5 +- cmd/tls/handlers/verify_sha256_test.go | 87 ++++++++++++++++++++++++++ deploy/config/tls.yml | 6 ++ pkg/config/types.go | 9 ++- pkg/types/types.go | 31 ++++++--- 6 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 cmd/tls/handlers/verify_sha256_test.go diff --git a/cmd/tls/handlers/handlers.go b/cmd/tls/handlers/handlers.go index dbc52176..7a339140 100644 --- a/cmd/tls/handlers/handlers.go +++ b/cmd/tls/handlers/handlers.go @@ -191,6 +191,31 @@ func WithPosture(pm *posture.PostureManager) Option { } } +// osqueryVersion returns the osquery version this deployment wants nodes to +// run: the configured one when set, otherwise the version osctrl was built +// against. The --osquery-version flag ("Version of osquery to be used") had no +// reader at all, so pinning a version in the config changed nothing and nodes +// were told the compile-time value regardless. +func (h *HandlersTLS) osqueryVersion() string { + if h.OsqueryValues != nil { + if v := strings.TrimSpace(h.OsqueryValues.Version); v != "" { + return v + } + } + return defOsqueryVersion +} + +// osquerySHA256 returns the configured digest of the osquery package, or an +// empty string when none is set. Empty is deliberate and safe: osctrld then +// falls back to its own --osquery-sha256 and, failing that, refuses to install +// an unverified package. +func (h *HandlersTLS) osquerySHA256() string { + if h.OsqueryValues == nil { + return "" + } + return strings.TrimSpace(h.OsqueryValues.SHA256) +} + // WithOsqueryValues to pass osquery configuration values func WithOsqueryValues(values *config.YAMLConfigurationOsquery) Option { return func(h *HandlersTLS) { diff --git a/cmd/tls/handlers/post.go b/cmd/tls/handlers/post.go index c5e42260..0ed4df3b 100644 --- a/cmd/tls/handlers/post.go +++ b/cmd/tls/handlers/post.go @@ -1050,7 +1050,8 @@ func (h *HandlersTLS) VerifyHandler(w http.ResponseWriter, r *http.Request) { response = types.VerifyResponse{ Certificate: env.Certificate, Flags: flagsStr, - OsqueryVersion: defOsqueryVersion, + OsqueryVersion: h.osqueryVersion(), + OsquerySHA256: h.osquerySHA256(), } } else { utils.HTTPResponse(w, "", http.StatusForbidden, []byte("")) @@ -1257,7 +1258,7 @@ func (h *HandlersTLS) EnrollPackageHandler(w http.ResponseWriter, r *http.Reques return } fDesc = "Enrolling MSI Package for Windows" - fName = genPackageFilename(env.Name, settings.PackageMsi, defOsqueryVersion, version.OsctrlVersion) + fName = genPackageFilename(env.Name, settings.PackageMsi, version.OsqueryVersion, version.OsctrlVersion) fPath, err = environments.PackageFilePath(enrollPackagesPath, env.Name, env.MsiPackage) } if err != nil { diff --git a/cmd/tls/handlers/verify_sha256_test.go b/cmd/tls/handlers/verify_sha256_test.go new file mode 100644 index 00000000..0dec81a4 --- /dev/null +++ b/cmd/tls/handlers/verify_sha256_test.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/jmpsec/osctrl/pkg/config" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/version" + "github.com/stretchr/testify/require" +) + +// osctrld refuses to install an osquery package it cannot verify, so what this +// field carries — and when it is deliberately empty — decides whether a fleet +// verifies its downloads or falls back to a per-node digest. + +func TestOsquerySHA256FromConfig(t *testing.T) { + digest := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{SHA256: digest}} + require.Equal(t, digest, h.osquerySHA256()) +} + +func TestOsquerySHA256IsTrimmed(t *testing.T) { + // A digest pasted into YAML picks up whitespace easily, and osctrld + // compares it against a hex string with no room for either side to trim. + h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{SHA256: " abc123\n"}} + require.Equal(t, "abc123", h.osquerySHA256()) +} + +func TestOsquerySHA256EmptyWhenUnset(t *testing.T) { + // Empty is the safe answer, not a failure: osctrld then uses its own + // --osquery-sha256, or refuses to install. Never invent a digest. + require.Empty(t, (&HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{}}).osquerySHA256()) + require.Empty(t, (&HandlersTLS{}).osquerySHA256(), "a nil osquery config must not panic") +} + +// The osquery version in the verify response is what osctrld installs and +// upgrades to, so a configured pin that the handler ignores means the whole +// fleet quietly tracks whatever osctrl was compiled against. + +func TestOsqueryVersionFromConfig(t *testing.T) { + h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{Version: "5.19.0"}} + require.Equal(t, "5.19.0", h.osqueryVersion()) + require.NotEqual(t, version.OsqueryVersion, h.osqueryVersion(), + "a configured pin must win over the build-time version") +} + +func TestOsqueryVersionIsTrimmed(t *testing.T) { + h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{Version: " 5.19.0\n"}} + require.Equal(t, "5.19.0", h.osqueryVersion()) +} + +func TestOsqueryVersionFallsBackToBuild(t *testing.T) { + // Unset and nil both mean "whatever this osctrl ships with" — never empty, + // which osctrld would compare against an installed version and misjudge. + require.Equal(t, version.OsqueryVersion, + (&HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{}}).osqueryVersion()) + require.Equal(t, version.OsqueryVersion, (&HandlersTLS{}).osqueryVersion()) +} + +// TestVerifyResponseWireFormat pins the JSON contract osctrld parses. The field +// name is matched verbatim by osctrld's own VerifyResponse struct; renaming it +// here silently disables verification on every node. +func TestVerifyResponseWireFormat(t *testing.T) { + raw, err := json.Marshal(types.VerifyResponse{ + Flags: "--flag", + Certificate: "cert", + OsqueryVersion: "5.23.1", + OsquerySHA256: "abc123", + }) + require.NoError(t, err) + + var wire map[string]any + require.NoError(t, json.Unmarshal(raw, &wire)) + require.Equal(t, "abc123", wire["osquery_sha256"], "osctrld reads osquery_sha256") + require.Contains(t, wire, "flags") + require.Contains(t, wire, "certificate") + require.Contains(t, wire, "osquery_version") + + // An unset digest still ships the key; osctrld treats "" as "no digest + // from the server" and falls back. Dropping the key entirely would be + // equivalent, but pinning the shape keeps the contract explicit. + raw, err = json.Marshal(types.VerifyResponse{OsqueryVersion: "5.23.1"}) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &wire)) + require.Equal(t, "", wire["osquery_sha256"]) +} diff --git a/deploy/config/tls.yml b/deploy/config/tls.yml index 8b246832..04a92854 100644 --- a/deploy/config/tls.yml +++ b/deploy/config/tls.yml @@ -185,6 +185,12 @@ rateLimits: osquery: # Empty-query dispatch cache lifetime; <=0 uses 2m. New queries invalidate it. queryDispatchTTL: 2m + # Expected SHA-256 of the osquery package nodes install. osctrld checks what + # it downloaded against this. Only set it when every node installs the same + # package: osquery ships a different artifact per format and architecture, + # and the verify request does not say which one a node will fetch. On a mixed + # fleet leave it empty and set --osquery-sha256 on the node instead. + sha256: "" # osquery schema version used for table metadata. version: 5.23.1 # JSON schema file with osquery table metadata. diff --git a/pkg/config/types.go b/pkg/config/types.go index b9666c69..6942eca4 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -231,7 +231,14 @@ type YAMLConfigurationRedis struct { // YAMLConfigurationOsquery to hold the osquery configuration values type YAMLConfigurationOsquery struct { - Version string `yaml:"version"` + Version string `yaml:"version"` + // SHA256 is the expected SHA-256 of the osquery package nodes install, + // handed to osctrld through the verify response so it can check what it + // downloaded. Only meaningful when every node installs the same package: + // osquery publishes a different artifact per format and architecture, and + // the verify request does not say which one the node will fetch. Leave it + // empty on a mixed fleet and set the digest on the node instead. + SHA256 string `yaml:"sha256"` TablesFile string `yaml:"tablesFile"` Logger bool `yaml:"logger"` Config bool `yaml:"config"` diff --git a/pkg/types/types.go b/pkg/types/types.go index fe46e527..e8405db5 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -57,6 +57,21 @@ type VerifyResponse struct { Flags string `json:"flags"` Certificate string `json:"certificate"` OsqueryVersion string `json:"osquery_version"` + // OsquerySHA256 is the expected SHA-256 of the osquery package osctrld + // should install. osctrld refuses to install an unverified package + // unless it is given a digest another way, so an empty value here is a + // safe default rather than a silent downgrade in security: the node + // falls back to its own --osquery-sha256, or refuses. + // + // One digest covers one artifact, and osquery ships a different package + // per format and architecture (deb amd64/arm64, rpm x86_64/aarch64, the + // macOS pkg, the Windows msi). osctrld picks its URL from its own + // runtime.GOOS/GOARCH and does not tell the server which one it will + // fetch, so osctrl can only answer this correctly for a deployment that + // installs a single package. Mixed fleets leave it unset and configure + // the digest on the node until the verify request carries the node's + // package format. + OsquerySHA256 string `json:"osquery_sha256"` } // ScriptRequest to retrieve script @@ -413,14 +428,14 @@ var SupportedLanguages = map[string]struct{}{ // UserMeResponse is the GET/PATCH /api/v1/users/me payload. type UserMeResponse struct { - Username string `json:"username"` - Email string `json:"email"` - Fullname string `json:"fullname"` - Admin bool `json:"admin"` - Service bool `json:"service"` - UUID string `json:"uuid"` - TokenExpire time.Time `json:"token_expire"` - LastAccess time.Time `json:"last_access"` + Username string `json:"username"` + Email string `json:"email"` + Fullname string `json:"fullname"` + Admin bool `json:"admin"` + Service bool `json:"service"` + UUID string `json:"uuid"` + TokenExpire time.Time `json:"token_expire"` + LastAccess time.Time `json:"last_access"` // PreferredLanguage mirrors AdminUser.PreferredLanguage — the // UI language the operator picked. Empty means "no server-side // preference"; the SPA falls back to its own detection. From e4cd92aeb4e68dcabb99737ddf35930c295058cf Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:27:49 +0200 Subject: [PATCH 2/2] Adds optional SSE-based live update hints between API/TLS and the frontend --- cmd/api/handlers/events.go | 7 +- cmd/api/handlers/events_test.go | 6 +- .../src/features/carves/CarveDetailPage.tsx | 25 +++++-- .../src/features/carves/CarvesListPage.tsx | 6 +- .../src/features/queries/QueriesListPage.tsx | 6 +- .../src/features/queries/QueryDetailPage.tsx | 15 ++--- frontend/src/lib/live-updates.test.tsx | 51 +++++++++++++- frontend/src/lib/live-updates.tsx | 67 +++++++++++++++---- pkg/carves/carves.go | 2 +- pkg/carves/events_test.go | 4 +- pkg/events/events.go | 9 ++- pkg/events/events_test.go | 18 +++-- pkg/logging/dispatch.go | 4 +- pkg/queries/events_test.go | 1 + pkg/queries/queries.go | 19 ++++-- 15 files changed, 184 insertions(+), 56 deletions(-) diff --git a/cmd/api/handlers/events.go b/cmd/api/handlers/events.go index 98f56249..1a0a5fd2 100644 --- a/cmd/api/handlers/events.go +++ b/cmd/api/handlers/events.go @@ -19,6 +19,7 @@ type ResourceChanged struct { EnvironmentUUID string `json:"environment_uuid"` Topic string `json:"topic"` Name string `json:"name"` + Change string `json:"change,omitempty"` } // EventsHandler streams authorized query/carve invalidations. @@ -162,7 +163,11 @@ func (h *HandlersApi) EventsHandler(w http.ResponseWriter, r *http.Request) { return } for hint := range pending { - if err := write("resource.changed", ResourceChanged{1, env.UUID, hint.Topic, hint.Name}); err != nil { + change := hint.Change + if change == "" { + change = events.ChangeMetadata + } + if err := write("resource.changed", ResourceChanged{SchemaVersion: 1, EnvironmentUUID: env.UUID, Topic: hint.Topic, Name: hint.Name, Change: change}); err != nil { return } delete(pending, hint) diff --git a/cmd/api/handlers/events_test.go b/cmd/api/handlers/events_test.go index 3593265b..d743bf99 100644 --- a/cmd/api/handlers/events_test.go +++ b/cmd/api/handlers/events_test.go @@ -98,8 +98,10 @@ func TestEventStreamRevocationAndCleanup(t *testing.T) { } } require.Contains(t, frame(), "stream.ready") - source.ch <- events.Hint{EnvironmentID: env.ID, Topic: events.Queries, Name: "query-1"} - require.Contains(t, frame(), "query-1") + source.ch <- events.Hint{EnvironmentID: env.ID, Topic: events.Queries, Name: "query-1", Change: events.ChangeResults} + queryFrame := frame() + require.Contains(t, queryFrame, "query-1") + require.Contains(t, queryFrame, `"change":"results"`) if revokeToken { valid.Store(false) } else { diff --git a/frontend/src/features/carves/CarveDetailPage.tsx b/frontend/src/features/carves/CarveDetailPage.tsx index e72f9909..6f1a7897 100644 --- a/frontend/src/features/carves/CarveDetailPage.tsx +++ b/frontend/src/features/carves/CarveDetailPage.tsx @@ -1,4 +1,4 @@ -import { useResourceUpdates } from '$/lib/live-updates'; +import { resourceRefetchInterval, useResourceUpdates } from '$/lib/live-updates'; import { useParams, useNavigate, Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; import { usePageTitle } from '$/lib/usePageTitle'; @@ -6,7 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getCarve, getCarveArchiveUrl, actOnCarve } from '$/api/carves'; import { listNodes } from '$/api/nodes'; import { AuthError } from '$/api/client'; -import type { CarveFile } from '$/api/types'; +import type { CarveDetail, CarveFile } from '$/api/types'; import { formatRelative } from '$/lib/time'; import { cn } from '$/lib/cn'; import { EmptyState } from '$/components/data/EmptyState'; @@ -66,8 +66,20 @@ function hasRealTimestamp(value?: string): boolean { return typeof value === 'string' && value.length > 0 && !value.startsWith('0001-01-01'); } +function carveRefetchInterval(live: boolean, detail?: CarveDetail) { + const query = detail?.query; + if (!query) return resourceRefetchInterval(live); + const status = (query.carve_status || '').toUpperCase(); + return resourceRefetchInterval(live, { + active: query.active || status === 'PENDING' || status === 'ACTIVE', + completed: query.completed || status === 'COMPLETED', + expired: query.expired || status === 'EXPIRED', + deleted: query.deleted || status === 'DELETED', + }); +} + export function CarveDetailPage() { - useResourceUpdates('carves'); + const carvesLive = useResourceUpdates('carves'); const { t } = useTranslation(); usePageTitle(t('pageTitle.carve')); const { env, name } = useParams({ from: '/_app/env/$env/carves/$name' }); @@ -77,7 +89,7 @@ export function CarveDetailPage() { queryKey: ['carve', env, name], queryFn: () => getCarve(env, name), staleTime: 15_000, - refetchInterval: 15_000, + refetchInterval: ({ state }) => carveRefetchInterval(carvesLive, state.data), }); const qc = useQueryClient(); const completeMutation = useMutation({ @@ -223,10 +235,9 @@ export function CarveDetailPage() { )}
- {/* Refresh button — mirrors the query detail page. Reloads the + {/* Refresh button mirrors the query detail page. Reloads the carve, its carved files, the carves list, and the node lookup so - an operator watching blocks land can pull the latest state now - instead of waiting for the 15s poll. */} + an operator watching blocks land can pull the latest state now. */}