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/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/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. */}