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