diff --git a/cmd/api/handlers/handlers.go b/cmd/api/handlers/handlers.go index 5f26ebef..67c090a8 100644 --- a/cmd/api/handlers/handlers.go +++ b/cmd/api/handlers/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/jmpsec/osctrl/pkg/geoip" "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/tags" @@ -34,6 +35,7 @@ type HandlersApi struct { RedisCache *cache.RedisManager Activity activityReader GeoIP *geoip.GeoIPResolver + Posture *posture.PostureManager ServiceVersion string ServiceName string AuditLog *auditlog.AuditLogManager @@ -131,6 +133,12 @@ func WithGeoIP(g *geoip.GeoIPResolver) HandlersOption { } } +func WithPosture(pm *posture.PostureManager) HandlersOption { + return func(h *HandlersApi) { + h.Posture = pm + } +} + func WithVersion(version string) HandlersOption { return func(h *HandlersApi) { h.ServiceVersion = version diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index 31b63419..1c25a929 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" @@ -598,3 +599,118 @@ func (h *HandlersApi) NodesPagedHandler(w http.ResponseWriter, r *http.Request) TotalPages: totalPages, }) } + +// PostureProfilesHandler — GET /api/v1/posture/profiles +// +// Returns all predefined posture check profiles that operators can merge +// into an environment's schedule config. +// @Summary List posture profiles +// @Description Returns predefined posture check profiles for different node types. +// @Tags posture +// @Produce json +// @Success 200 {array} posture.PostureProfile +// @Security ApiKeyAuth +// @Router /api/v1/posture/profiles [get] +func (h *HandlersApi) PostureProfilesHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, posture.AllProfiles()) +} + +// PostureProfileHandler — GET /api/v1/posture/profiles/{id} +// +// Returns a single posture profile by ID. +// @Summary Get posture profile +// @Description Returns a single posture check profile by ID. +// @Tags posture +// @Produce json +// @Param id path string true "Profile ID" +// @Success 200 {object} posture.PostureProfile +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Security ApiKeyAuth +// @Router /api/v1/posture/profiles/{id} [get] +func (h *HandlersApi) PostureProfileHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + profileID := r.PathValue("id") + if profileID == "" { + apiErrorResponse(w, "profile id required", http.StatusBadRequest, nil) + return + } + profile := posture.GetProfile(profileID) + if profile == nil { + apiErrorResponse(w, "profile not found", http.StatusNotFound, nil) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, profile) +} + +// NodePostureHandler — GET /api/v1/nodes/{env}/node/{uuid}/posture +// +// Returns all posture categories for a node (latest snapshot per category). +// @Summary Get node posture +// @Description Returns security and compliance posture data for a node. +// @Tags nodes +// @Produce json +// @Param env path string true "Environment name or UUID" +// @Param uuid path string true "Node UUID" +// @Success 200 {array} posture.NodePosture +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 404 {object} types.ApiErrorResponse "Not found" +// @Security ApiKeyAuth +// @Router /api/v1/nodes/{env}/node/{uuid}/posture [get] +func (h *HandlersApi) NodePostureHandler(w http.ResponseWriter, r *http.Request) { + ctxVal := r.Context().Value(ContextKey(contextAPI)) + if ctxVal == nil { + apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) + return + } + ctx := ctxVal.(ContextValue) + user := ctx[ctxUser] + + envVar := r.PathValue("env") + uuidVar := r.PathValue("uuid") + if envVar == "" || uuidVar == "" { + apiErrorResponse(w, "env and uuid required", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + apiErrorResponse(w, "error getting environment", http.StatusNotFound, err) + return + } + if !h.Users.CheckPermissions(user, users.UserLevel, env.UUID) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt by %s", user)) + return + } + node, err := h.Nodes.GetByUUID(uuidVar) + if err != nil { + apiErrorResponse(w, "node not found", http.StatusNotFound, err) + return + } + if !strings.EqualFold(node.Environment, env.Name) { + apiErrorResponse(w, "node not in environment", http.StatusForbidden, nil) + return + } + if h.Posture == nil { + apiErrorResponse(w, "posture not configured", http.StatusServiceUnavailable, nil) + return + } + records, err := h.Posture.GetByNode(node.UUID) + if err != nil { + apiErrorResponse(w, "error getting posture", http.StatusInternalServerError, err) + return + } + if records == nil { + records = []posture.NodePosture{} + } + h.AuditLog.NodeAction(ctx[ctxUser], "viewed posture for "+uuidVar, strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, records) +} diff --git a/cmd/api/main.go b/cmd/api/main.go index 92b3f31b..e944b8ac 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -23,6 +23,7 @@ import ( "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/nodes" "github.com/jmpsec/osctrl/pkg/osquery" + "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/ratelimit" "github.com/jmpsec/osctrl/pkg/settings" @@ -296,6 +297,7 @@ func osctrlAPIService() { log.Info().Msg("Initialize environment") envs = environments.CreateEnvironment(db.Conn) + posturemgr := posture.NewPostureManager(db.Conn) // Initialize settings log.Info().Msg("Initialize settings") settingsmgr = settings.NewSettings(db.Conn) @@ -366,6 +368,7 @@ func osctrlAPIService() { handlers.WithCache(redis), handlers.WithActivityReader(activity.NewRedisStore(redis.Client, activity.DefaultPrefix, activity.DefaultRetentionDays, 8*24*time.Hour)), handlers.WithGeoIP(geoIPResolver), + handlers.WithPosture(posturemgr), handlers.WithVersion(buildVersion), handlers.WithName(serviceName), handlers.WithAuditLog(auditLog), @@ -480,6 +483,17 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiNodesPath)+"/{env}", handlerAuthCheck(http.HandlerFunc(handlersApi.NodesPagedHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: node posture (security & compliance data) + muxAPI.Handle( + "GET "+_apiPath(apiNodesPath)+"/{env}/node/{uuid}/posture", + handlerAuthCheck(http.HandlerFunc(handlersApi.NodePostureHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // API: posture profiles (predefined check templates) + muxAPI.Handle( + "GET "+_apiPath("/posture")+"/profiles", + handlerAuthCheck(http.HandlerFunc(handlersApi.PostureProfilesHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath("/posture")+"/profiles/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.PostureProfileHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) // API: node logs muxAPI.Handle( "GET "+_apiPath(apiLogsPath)+"/{type}/{env}/{uuid}", diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 04ace47a..eaa8c9bd 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -22,6 +22,7 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" "golang.org/x/term" + "gorm.io/gorm/logger" "github.com/urfave/cli/v3" ) @@ -76,6 +77,12 @@ var ( formats map[string]bool ) +func quietDBLogger() { + if db != nil && db.Conn != nil { + db.Conn.Logger = logger.Discard + } +} + // Variables for flags var ( dbFlag bool @@ -1922,6 +1929,7 @@ func checkDB(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("failed to create backend - %w", err) } } + quietDBLogger() if err := db.Check(); err != nil { return err } @@ -2080,6 +2088,7 @@ func cliWrapper(action func(context.Context, *cli.Command) error) func(context.C return fmt.Errorf("in CreateDBManager - %w", err) } } + quietDBLogger() // Initialize users. The CLI manages user/permission rows directly // and never mints JWTs, so we skip WithJWT — CreateToken fails // fast if anything in this binary ever tries to call it. diff --git a/cmd/tls/handlers/handlers.go b/cmd/tls/handlers/handlers.go index 9bbd438c..bc2974e6 100644 --- a/cmd/tls/handlers/handlers.go +++ b/cmd/tls/handlers/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/tags" @@ -62,6 +63,7 @@ type HandlersTLS struct { Logs *logging.LoggerTLS WriteHandler *batchWriter ActivityWriter *activityWriter + Posture *posture.PostureManager OsqueryValues *config.YAMLConfigurationOsquery ConfigEndpoints *config.YAMLConfigurationEndpoints DebugHTTP *zerolog.Logger @@ -163,6 +165,12 @@ func WithActivityWriter(activityWriter *activityWriter) Option { } } +func WithPosture(pm *posture.PostureManager) Option { + return func(h *HandlersTLS) { + h.Posture = pm + } +} + // 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 b249a411..e4b35727 100644 --- a/cmd/tls/handlers/post.go +++ b/cmd/tls/handlers/post.go @@ -19,6 +19,7 @@ import ( "github.com/jmpsec/osctrl/pkg/activity" "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/settings" "github.com/jmpsec/osctrl/pkg/types" @@ -356,9 +357,13 @@ func (h *HandlersTLS) LogHandler(w http.ResponseWriter, r *http.Request) { // Process logs and update metadata go func() { start := time.Now() - h.Logs.ProcessLogs(t.Data, t.LogType, env.Name, utils.GetIP(r), len(body), (*h.EnvsMap)[env.Name].DebugHTTP) + results := h.Logs.ProcessLogs(t.Data, t.LogType, env.Name, utils.GetIP(r), len(body), (*h.EnvsMap)[env.Name].DebugHTTP) duration := time.Since(start).Seconds() logProcessDuration.WithLabelValues(string(env.UUID), t.LogType).Observe(duration) + // Ingest posture data from result logs (if enabled) + if h.Posture != nil && t.LogType == "result" { + h.ingestPosture(results, node.UUID, env.Name) + } }() } else { nodeInvalid = true @@ -1388,3 +1393,43 @@ func (h *HandlersTLS) OsqueryConfigEndpointHandler(w http.ResponseWriter, r *htt // Send response utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, response) } + +// ingestPosture parses result log entries and feeds any posture-prefixed +// query results into the PostureManager. +func (h *HandlersTLS) ingestPosture(results []types.LogResultData, nodeUUID, environment string) { + grouped := make(map[string][]json.RawMessage) + var queryOrder []string + for _, r := range results { + if !posture.IsPostureQuery(r.Name) { + continue + } + if _, ok := grouped[r.Name]; !ok { + queryOrder = append(queryOrder, r.Name) + grouped[r.Name] = []json.RawMessage{} + } + var rows []json.RawMessage + trimmed := bytes.TrimSpace(r.Columns) + switch { + case len(trimmed) == 0: + rows = []json.RawMessage{} + case trimmed[0] == '[': + if err := json.Unmarshal(trimmed, &rows); err != nil { + log.Warn().Err(err).Str("query", r.Name).Msg("posture: failed to parse result rows") + continue + } + default: + rows = []json.RawMessage{json.RawMessage(trimmed)} + } + grouped[r.Name] = append(grouped[r.Name], rows...) + } + for _, queryName := range queryOrder { + columns, err := json.Marshal(grouped[queryName]) + if err != nil { + log.Warn().Err(err).Str("query", queryName).Msg("posture: failed to marshal grouped result") + continue + } + if err := h.Posture.IngestResult(nodeUUID, environment, queryName, columns); err != nil { + log.Warn().Err(err).Str("query", queryName).Msg("posture: failed to ingest result") + } + } +} diff --git a/cmd/tls/handlers/posture_test.go b/cmd/tls/handlers/posture_test.go new file mode 100644 index 00000000..9eac4d77 --- /dev/null +++ b/cmd/tls/handlers/posture_test.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "encoding/json" + "testing" + + "github.com/jmpsec/osctrl/pkg/posture" + "github.com/jmpsec/osctrl/pkg/types" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newPostureTestHandler(t *testing.T) *HandlersTLS { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + return &HandlersTLS{Posture: posture.NewPostureManager(db)} +} + +func TestIngestPostureAggregatesRowsFromSameResultBatch(t *testing.T) { + h := newPostureTestHandler(t) + results := []types.LogResultData{ + { + Name: posture.QueryPrefix + "users", + Columns: json.RawMessage(`{"username":"alice","uid":"501"}`), + }, + { + Name: posture.QueryPrefix + "users", + Columns: json.RawMessage(`{"username":"bob","uid":"502"}`), + }, + } + + h.ingestPosture(results, "node-a", "dev") + + records, err := h.Posture.GetByNode("node-a") + if err != nil { + t.Fatalf("get posture: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one posture category, got %d", len(records)) + } + if records[0].RowCount != 2 { + t.Fatalf("expected two rows in aggregated posture result, got %d", records[0].RowCount) + } + + var summary []map[string]string + if err := json.Unmarshal([]byte(records[0].Summary), &summary); err != nil { + t.Fatalf("parse summary: %v", err) + } + if len(summary) != 2 || summary[0]["username"] != "alice" || summary[1]["username"] != "bob" { + t.Fatalf("unexpected summary: %+v", summary) + } +} + +func TestIngestPostureStoresEmptySnapshotResults(t *testing.T) { + h := newPostureTestHandler(t) + results := []types.LogResultData{ + { + Name: posture.QueryPrefix + "empty", + Columns: nil, + }, + } + + h.ingestPosture(results, "node-a", "dev") + + records, err := h.Posture.GetByNode("node-a") + if err != nil { + t.Fatalf("get posture: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one posture category, got %d", len(records)) + } + if records[0].RowCount != 0 { + t.Fatalf("expected zero rows for empty snapshot, got %d", records[0].RowCount) + } + if records[0].Summary != "[]" { + t.Fatalf("expected empty JSON array summary, got %q", records[0].Summary) + } +} diff --git a/cmd/tls/main.go b/cmd/tls/main.go index 7ee01ea9..8d4611c6 100644 --- a/cmd/tls/main.go +++ b/cmd/tls/main.go @@ -21,6 +21,7 @@ import ( "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/logging" "github.com/jmpsec/osctrl/pkg/nodes" + "github.com/jmpsec/osctrl/pkg/posture" "github.com/jmpsec/osctrl/pkg/queries" "github.com/jmpsec/osctrl/pkg/ratelimit" "github.com/jmpsec/osctrl/pkg/settings" @@ -223,8 +224,10 @@ func osctrlService() { } log.Info().Msg("Initialize settings") settingsmgr = settings.NewSettings(db.Conn) + posture.SetPrefix(flagParams.Service.PostureQueryPrefix) log.Info().Msg("Initialize nodes") nodesmgr = nodes.CreateNodes(db.Conn) + posturemgr := posture.NewPostureManager(db.Conn) log.Info().Msg("Initialize tags") tagsmgr = tags.CreateTagManager(db.Conn) log.Info().Msg("Initialize queries") @@ -329,6 +332,7 @@ func osctrlService() { handlers.WithLogs(loggerTLS), handlers.WithWriteHandler(tlsWriter), handlers.WithActivityWriter(activityWriter), + handlers.WithPosture(posturemgr), handlers.WithOsqueryValues(flagParams.Osquery), handlers.WithConfigEndpoints(flagParams.ConfigEndpoints), handlers.WithDebugHTTP(flagParams.Debug), diff --git a/deploy/config/tls.yml b/deploy/config/tls.yml index a5c379d5..ee9e268a 100644 --- a/deploy/config/tls.yml +++ b/deploy/config/tls.yml @@ -18,6 +18,13 @@ service: # prevents header-spoofed enroll-rate-limit bypass and audit-log # poisoning. trustedProxies: "" + # Prefix for scheduled query names whose results are ingested as + # node posture data (security & compliance). Queries in the osquery + # schedule named e.g. "osctrl:posture:packages" will have their + # results stored as the "packages" posture category. + # + # Empty disables posture ingestion entirely. + postureQueryPrefix: "osctrl:posture:" # Database configuration db: diff --git a/frontend/src/api/nodes.ts b/frontend/src/api/nodes.ts index bde21bb2..3e6153d7 100644 --- a/frontend/src/api/nodes.ts +++ b/frontend/src/api/nodes.ts @@ -1,4 +1,5 @@ import { apiFetch } from './client'; +import type { NodePosture, PostureProfile } from './types'; import type { NodesPagedResponse, OsqueryNode, @@ -86,3 +87,17 @@ export function listNodeLogs( `/api/v1/logs/${encodeURIComponent(type)}/${encodeURIComponent(env)}/${encodeURIComponent(uuid)}${qs ? `?${qs}` : ''}`, ); } + +export function getNodePosture(env: string, uuid: string): Promise { + return apiFetch( + `/api/v1/nodes/${encodeURIComponent(env)}/node/${encodeURIComponent(uuid)}/posture`, + ); +} + +export function getPostureProfiles(): Promise { + return apiFetch('/api/v1/posture/profiles'); +} + +export function getPostureProfile(id: string): Promise { + return apiFetch(`/api/v1/posture/profiles/${encodeURIComponent(id)}`); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f0776136..ebdbb009 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -410,3 +410,32 @@ export interface OsqueryTable { platforms: string[]; filter: string; } + +export interface NodePosture { + id: number; + created_at: string; + updated_at: string; + node_uuid: string; + environment: string; + category: string; + query_name: string; + row_count: number; + summary: string; + first_seen: string; + last_seen: string; +} + +export interface ProfileQuery { + query: string; + interval: number; + platform?: string; + snapshot: boolean; +} + +export interface PostureProfile { + id: string; + name: string; + description: string; + platform: string; + queries: Record; +} diff --git a/frontend/src/features/environments/EnvConfigPage.test.tsx b/frontend/src/features/environments/EnvConfigPage.test.tsx index fa34e308..c5ff5527 100644 --- a/frontend/src/features/environments/EnvConfigPage.test.tsx +++ b/frontend/src/features/environments/EnvConfigPage.test.tsx @@ -20,6 +20,7 @@ const { mockPatchConfig, mockPatchIntervals, mockPatchExpiration, + mockGetPostureProfiles, } = vi.hoisted(() => ({ mockGetEnvironment: vi.fn<() => Promise>(), mockGetConfig: vi.fn<() => Promise>(), @@ -27,6 +28,7 @@ const { mockPatchConfig: vi.fn(), mockPatchIntervals: vi.fn(), mockPatchExpiration: vi.fn(), + mockGetPostureProfiles: vi.fn(), })); vi.mock('$/api/environments', () => ({ @@ -52,6 +54,10 @@ vi.mock('$/api/client', () => ({ }, })); +vi.mock('$/api/nodes', () => ({ + getPostureProfiles: (...args: unknown[]) => mockGetPostureProfiles(...args), +})); + vi.mock('$/components/forms/CodeEditor', () => ({ CodeEditor: ({ value, 'aria-label': ariaLabel }: { value: string; 'aria-label'?: string }) => (
@@ -166,6 +172,7 @@ describe('EnvConfigPage', () => { mockGetAssembledConfig.mockResolvedValue({ data: '{"options":{"logger_plugin":"tls"}}', }); + mockGetPostureProfiles.mockResolvedValue([]); }); it('loads the fully rendered tab from the assembled config endpoint', async () => { @@ -206,4 +213,36 @@ describe('EnvConfigPage', () => { expect(mockGetAssembledConfig).toHaveBeenCalledTimes(2); }); }); + + it('loads posture profiles only after opening the picker', async () => { + const user = userEvent.setup(); + + renderWithProviders(); + + const scheduleTab = await screen.findByRole('tab', { name: 'Schedule' }); + expect(mockGetPostureProfiles).not.toHaveBeenCalled(); + + await user.click(scheduleTab); + expect(mockGetPostureProfiles).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Add posture checks' })); + + await waitFor(() => { + expect(mockGetPostureProfiles).toHaveBeenCalledTimes(1); + }); + }); + + it('distinguishes a profile load failure from an empty profile list', async () => { + const user = userEvent.setup(); + mockGetPostureProfiles.mockRejectedValue(new Error('profiles unavailable')); + + renderWithProviders(); + + await user.click(await screen.findByRole('tab', { name: 'Schedule' })); + await user.click(screen.getByRole('button', { name: 'Add posture checks' })); + + expect(await screen.findByText('Failed to load posture profiles.', {}, { timeout: 3_000 })).toBeInTheDocument(); + expect(screen.queryByText('No posture profiles available.')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry loading profiles' })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/features/environments/EnvConfigPage.tsx b/frontend/src/features/environments/EnvConfigPage.tsx index 6872c873..f930dff9 100644 --- a/frontend/src/features/environments/EnvConfigPage.tsx +++ b/frontend/src/features/environments/EnvConfigPage.tsx @@ -13,6 +13,9 @@ import { type EnvExpirationAction, } from '$/api/environments'; import { AuthError, ApiError } from '$/api/client'; +import { getPostureProfiles } from '$/api/nodes'; +import type { PostureProfile } from '$/api/types'; +import { applyPostureProfileToSchedule } from './postureSchedule'; import { cn } from '$/lib/cn'; import { CodeEditor } from '$/components/forms/CodeEditor'; import { DiffView } from '$/components/forms/DiffView'; @@ -21,6 +24,13 @@ import { AssembledConfigCard } from '$/features/enrollment/AssembledConfigCard'; type SectionKey = 'options' | 'schedule' | 'packs' | 'decorators' | 'atc' | 'flags'; +const POSTURE_INTERVALS = [ + { label: 'Daily', seconds: 86400, description: 'Once per day — sufficient for compliance' }, + { label: 'Every 12h', seconds: 43200, description: 'Twice per day — faster detection' }, + { label: 'Every 6h', seconds: 21600, description: 'Four times per day — active monitoring' }, + { label: 'Every 1h', seconds: 3600, description: 'Hourly — high-frequency monitoring' }, +]; + // docs URLs point at the upstream osquery read-the-docs anchors so an // operator can jump from the section header straight to the canonical // reference for that part of the config. `latest` is intentional — pinning @@ -108,6 +118,16 @@ export function EnvConfigPage() { // they save or explicitly reset. const [draft, setDraft] = useState(null); const [saveErr, setSaveErr] = useState(null); + const [showPosturePicker, setShowPosturePicker] = useState(false); + const [postureAggressiveness, setPostureAggressiveness] = useState(1); + const postureProfilesQuery = useQuery({ + queryKey: ['posture-profiles'], + queryFn: () => getPostureProfiles(), + enabled: showPosturePicker, + staleTime: 5 * 60_000, + retry: 1, + }); + const postureProfiles = postureProfilesQuery.data; const [diffsOpen, setDiffsOpen] = useState>({ options: false, schedule: false, @@ -193,6 +213,19 @@ export function EnvConfigPage() { }, }); + function applyPostureProfile(profile: PostureProfile) { + const interval = POSTURE_INTERVALS[postureAggressiveness - 1]?.seconds ?? 86400; + if (!draft) return; + try { + const schedule = applyPostureProfileToSchedule(draft.schedule, profile, interval); + setDraft({ ...draft, schedule }); + setSaveErr(null); + setShowPosturePicker(false); + } catch (error) { + setSaveErr(error instanceof Error ? error.message : 'Schedule must be a JSON object.'); + } + } + if (envQuery.isError && envQuery.error instanceof AuthError) { void navigate({ to: '/login' }); return null; @@ -404,12 +437,28 @@ export function EnvConfigPage() { /> )} {key === 'schedule' && ( - - setDraft((d) => (d ? { ...d, schedule: next } : d)) - } - /> + <> +
+ Quick add + +
+ + setDraft((d) => (d ? { ...d, schedule: next } : d)) + } + /> + )} + {showPosturePicker && ( +
setShowPosturePicker(false)}> +
e.stopPropagation()}> +
+

Posture check profiles

+ +
+
+ {/* Aggressiveness slider */} +
+
+ Check frequency + + {POSTURE_INTERVALS[postureAggressiveness - 1]?.label ?? 'Daily'} + +
+ setPostureAggressiveness(Number(e.target.value))} + className="w-full accent-[color:var(--signal)]" + /> +
+ {POSTURE_INTERVALS.map((p, i) => ( + setPostureAggressiveness(i + 1)} + > + {p.label} + + ))} +
+

+ {POSTURE_INTERVALS[postureAggressiveness - 1]?.description} +

+
+ + {/* Profile cards */} +
+ {postureProfilesQuery.isLoading && ( +

Loading posture profiles…

+ )} + {postureProfilesQuery.isError && ( +
+

Failed to load posture profiles.

+ +
+ )} + {(postureProfiles ?? []).map((profile: PostureProfile) => ( +
+
+
+ {profile.name} + {profile.platform} +
+ +
+

{profile.description}

+
+ {Object.keys(profile.queries).map((name) => ( + {name} + ))} +
+
+ ))} + {postureProfilesQuery.isSuccess && (postureProfiles?.length ?? 0) === 0 && ( +

No posture profiles available.

+ )} +
+
+
+
+ )}
); } diff --git a/frontend/src/features/environments/postureSchedule.test.ts b/frontend/src/features/environments/postureSchedule.test.ts new file mode 100644 index 00000000..887e3bc1 --- /dev/null +++ b/frontend/src/features/environments/postureSchedule.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import type { PostureProfile } from '$/api/types'; +import { applyPostureProfileToSchedule } from './postureSchedule'; + +const profile: PostureProfile = { + id: 'linux-server', + name: 'Linux Servers', + description: 'Test profile', + platform: 'linux', + queries: { + users: { + query: 'SELECT username FROM users', + interval: 86400, + snapshot: true, + }, + packages: { + query: 'SELECT name FROM deb_packages', + interval: 86400, + platform: 'linux', + snapshot: true, + }, + }, +}; + +describe('applyPostureProfileToSchedule', () => { + it('merges fixed-prefix entries and replaces matching drafts', () => { + const schedule = JSON.stringify({ + existing: { query: 'SELECT 1', interval: 60 }, + 'osctrl:posture:users': { query: 'old query', interval: 10 }, + }); + + const result = JSON.parse(applyPostureProfileToSchedule(schedule, profile, 3600)); + + expect(result.existing).toEqual({ query: 'SELECT 1', interval: 60 }); + expect(result['osctrl:posture:users']).toEqual({ + query: 'SELECT username FROM users', + interval: 3600, + snapshot: true, + }); + expect(result['osctrl:posture:packages']).toEqual({ + query: 'SELECT name FROM deb_packages', + interval: 3600, + platform: 'linux', + snapshot: true, + }); + }); + + it.each(['{"broken"', '[]', 'null', '"text"'])( + 'rejects a schedule that is not a JSON object: %s', + (schedule) => { + expect(() => applyPostureProfileToSchedule(schedule, profile, 86400)).toThrow( + 'Schedule must be a JSON object.', + ); + }, + ); +}); diff --git a/frontend/src/features/environments/postureSchedule.ts b/frontend/src/features/environments/postureSchedule.ts new file mode 100644 index 00000000..182b9ba9 --- /dev/null +++ b/frontend/src/features/environments/postureSchedule.ts @@ -0,0 +1,30 @@ +import type { PostureProfile } from '$/api/types'; + +const POSTURE_QUERY_PREFIX = 'osctrl:posture:'; + +export function applyPostureProfileToSchedule( + schedule: string, + profile: PostureProfile, + interval: number, +): string { + let parsed: unknown; + try { + parsed = JSON.parse(schedule || '{}'); + } catch { + throw new Error('Schedule must be a JSON object.'); + } + if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error('Schedule must be a JSON object.'); + } + + const entries = parsed as Record; + for (const [name, query] of Object.entries(profile.queries)) { + entries[POSTURE_QUERY_PREFIX + name] = { + query: query.query, + interval, + snapshot: query.snapshot, + ...(query.platform ? { platform: query.platform } : {}), + }; + } + return JSON.stringify(entries, null, 2); +} diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index 02a9cadf..4a99717a 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -11,13 +11,14 @@ import { Outlet, } from '@tanstack/react-router'; import { NodeDetailPage } from './NodeDetailPage'; -import type { OsqueryNode } from '$/api/types'; +import type { NodePosture, OsqueryNode } from '$/api/types'; import type { NodeActivityBucket, NodeTileSeries } from '$/api/stats'; import type { SettingValue } from '$/api/settings'; const mockGetNode = vi.fn<() => Promise>(); const mockListNodeLogs = vi.fn<() => Promise>(); const mockDeleteNode = vi.fn<() => Promise<{ message: string }>>(); +const mockGetNodePosture = vi.fn<() => Promise>(); const mockGetMe = vi.fn<() => Promise>(); const mockListEnvironments = vi.fn<() => Promise>>(); const mockGetNodeActivity = vi.fn<() => Promise>(); @@ -28,6 +29,7 @@ vi.mock('$/api/nodes', () => ({ getNode: (...args: unknown[]) => mockGetNode(...(args as [])), listNodeLogs: (...args: unknown[]) => mockListNodeLogs(...(args as [])), deleteNode: (...args: unknown[]) => mockDeleteNode(...(args as [])), + getNodePosture: (...args: unknown[]) => mockGetNodePosture(...(args as [])), })); vi.mock('$/api/users', () => ({ @@ -189,6 +191,7 @@ describe('NodeDetailPage', () => { limit: 100, }); mockDeleteNode.mockResolvedValue({ message: 'ok' }); + mockGetNodePosture.mockResolvedValue([]); mockGetMe.mockResolvedValue({ admin: true, permissions: {} }); mockListEnvironments.mockResolvedValue([{ name: 'test-env', uuid: 'env-uuid-1' }]); mockGetNodeActivity.mockResolvedValue(makeActivityBuckets()); @@ -269,4 +272,47 @@ describe('NodeDetailPage', () => { vi.unstubAllGlobals(); }); + + it('shows posture data for the selected node', async () => { + const user = userEvent.setup(); + mockGetNodePosture.mockResolvedValue([ + { + id: 10, + created_at: '2026-07-16T09:00:00Z', + updated_at: '2026-07-16T09:05:00Z', + node_uuid: 'abc12345-0000-0000-0000-000000000001', + environment: 'test-env', + category: 'firewall', + query_name: 'osctrl:posture:firewall', + row_count: 2, + summary: JSON.stringify([{ enabled: '1', profile: 'domain' }]), + first_seen: '2026-07-16T09:00:00Z', + last_seen: '2026-07-16T09:05:00Z', + }, + ]); + + renderWithProviders(makeTestRouter()); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'web-server-01' })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('tab', { name: 'Posture' })); + + await waitFor(() => { + expect(mockGetNodePosture).toHaveBeenCalledWith( + 'test-env', + 'abc12345-0000-0000-0000-000000000001', + ); + }); + + expect(screen.getByRole('button', { name: /firewall/i })).toHaveTextContent('2 rows'); + + await user.click(screen.getByRole('button', { name: /firewall/i })); + + expect(screen.getByText('enabled:')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('profile:')).toBeInTheDocument(); + expect(screen.getByText('domain')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/features/nodes/NodeDetailPage.tsx b/frontend/src/features/nodes/NodeDetailPage.tsx index e45bafd8..92bbd681 100644 --- a/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.tsx @@ -2,7 +2,8 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { usePageTitle } from '$/lib/usePageTitle'; import { useParams, Link, useNavigate } from '@tanstack/react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getNode, listNodeLogs, deleteNode } from '$/api/nodes'; +import { getNode, listNodeLogs, deleteNode, getNodePosture } from '$/api/nodes'; +import type { NodePosture } from '$/api/types'; import { getMe } from '$/api/users'; import { listEnvironments } from '$/api/environments'; import { @@ -45,7 +46,7 @@ interface NodeHeatmapBucket { // Tabs // --------------------------------------------------------------------------- -type Tab = 'details' | 'status-logs' | 'result-logs'; +type Tab = 'details' | 'status-logs' | 'result-logs' | 'posture'; // --------------------------------------------------------------------------- // Detail field groups @@ -560,6 +561,7 @@ const TABS = [ { id: 'details' as Tab, label: 'Details' }, { id: 'status-logs' as Tab, label: 'Status logs' }, { id: 'result-logs' as Tab, label: 'Result logs' }, + { id: 'posture' as Tab, label: 'Posture' }, ] as const; export function NodeDetailPage() { @@ -1099,6 +1101,9 @@ export function NodeDetailPage() { )} + {activeTab === 'posture' && ( + + )} {activeTab === 'status-logs' && ( )} @@ -1536,3 +1541,117 @@ function NodeFragmentRow({ } export default NodeDetailPage; + + +// --------------------------------------------------------------------------- +// PostureTab — security & compliance posture from scheduled prefixed queries. +// --------------------------------------------------------------------------- +function PostureTab({ env, uuid }: { env: string; uuid: string }) { + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['node-posture', env, uuid], + queryFn: () => getNodePosture(env, uuid), + staleTime: 30_000, + retry: 1, + }); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (isError) { + return ( + + + + + } + title={error instanceof Error ? error.message : 'Failed to load posture'} + /> + ); + } + + if (!data || data.length === 0) { + return ( + + + + } + title="No posture data." + description="Add scheduled queries prefixed with osctrl:posture: to the environment's schedule config to collect compliance data." + /> + ); + } + + return ( +
+ {data.map((item) => ( + + ))} +
+ ); +} + +function PostureCard({ item }: { item: NodePosture }) { + const [expanded, setExpanded] = useState(false); + const summary = (() => { + try { + return JSON.parse(item.summary) as Record[]; + } catch { + return []; + } + })(); + + return ( +
+ + {expanded && summary.length > 0 && ( +
+ + {summary.map((row, i) => { + const entries = Object.entries(row); + return ( + + {entries.map(([col, val]) => ( + + ))} + + ); + })} +
+ {col}: + {typeof val === 'string' ? val : JSON.stringify(val)} +
+
+ )} +
+ ); +} diff --git a/pkg/config/flags.go b/pkg/config/flags.go index debe68eb..7c888b4e 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -257,6 +257,13 @@ func initServiceFlags(params *ServiceParameters) []cli.Flag { Sources: cli.EnvVars("SERVICE_GEOIP_DB"), Destination: ¶ms.Service.GeoIPDBPath, }, + &cli.StringFlag{ + Name: "posture-query-prefix", + Value: "osctrl:posture:", + Usage: "Prefix for scheduled query names whose results are ingested as node posture data. Empty disables posture ingestion.", + Sources: cli.EnvVars("SERVICE_POSTURE_QUERY_PREFIX"), + Destination: ¶ms.Service.PostureQueryPrefix, + }, } } diff --git a/pkg/config/types.go b/pkg/config/types.go index 9d6ffb84..5b9eaf61 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -123,8 +123,12 @@ type YAMLConfigurationService struct { // includes them in the node API response. When empty (default), the // feature is disabled and no country codes are returned. GeoIPDBPath string `yaml:"geoipDBPath"` - Auth string `yaml:"auth"` - AuditLog bool `yaml:"auditLog"` + // PostureQueryPrefix is the prefix that identifies scheduled queries + // whose result logs should be ingested as node posture data. Empty + // disables posture ingestion. + PostureQueryPrefix string `yaml:"postureQueryPrefix"` + Auth string `yaml:"auth"` + AuditLog bool `yaml:"auditLog"` // TrustedProxies is a comma-separated list of CIDRs whose // X-Real-IP / X-Forwarded-For headers utils.GetIP will honor. // Default empty → forwarding headers are ignored and the diff --git a/pkg/logging/db.go b/pkg/logging/db.go index 491268df..a48d8fa8 100644 --- a/pkg/logging/db.go +++ b/pkg/logging/db.go @@ -134,8 +134,8 @@ func (logDB *LoggerDB) Status(data []byte, environment, uuid string, debug bool) // Result - Function that sends JSON result logs to the configured DB func (logDB *LoggerDB) Result(data []byte, environment, uuid string, debug bool) { // Parse JSON - var logs []types.LogResultData - if err := json.Unmarshal(data, &logs); err != nil { + logs, err := parseResultLogs(data) + if err != nil { log.Err(err).Msgf("error parsing logs %s", string(data)) } // Iterate and insert in DB diff --git a/pkg/logging/process.go b/pkg/logging/process.go index c4a1205d..4199f3de 100644 --- a/pkg/logging/process.go +++ b/pkg/logging/process.go @@ -8,11 +8,39 @@ import ( "github.com/rs/zerolog/log" ) -// ProcessLogs - Helper to process logs -func (l *LoggerTLS) ProcessLogs(data json.RawMessage, logType, environment, ipaddress string, dataLen int, debug bool) { +func parseResultLogs(data json.RawMessage) ([]types.LogResultData, error) { + var logs []types.LogResultData + if err := json.Unmarshal(data, &logs); err != nil { + return nil, err + } + for i := range logs { + if len(logs[i].Columns) == 0 && len(logs[i].Snapshot) > 0 { + logs[i].Columns = logs[i].Snapshot + } + } + return logs, nil +} + +// ProcessLogs processes and dispatches logs. Result entries are returned so +// callers can reuse the decoded batch for secondary consumers such as posture. +func (l *LoggerTLS) ProcessLogs(data json.RawMessage, logType, environment, ipaddress string, dataLen int, debug bool) []types.LogResultData { // Parse log to extract metadata var logs []types.LogGenericData - if err := json.Unmarshal(data, &logs); err != nil { + var resultLogs []types.LogResultData + var err error + if logType == types.ResultLog { + resultLogs, err = parseResultLogs(data) + logs = make([]types.LogGenericData, len(resultLogs)) + for i, result := range resultLogs { + logs[i] = types.LogGenericData{ + HostIdentifier: result.HostIdentifier, + Decorations: result.Decorations, + } + } + } else { + err = json.Unmarshal(data, &logs) + } + if err != nil { // FIXME metrics for this log.Err(err).Msgf("error parsing log %s", string(data)) } @@ -47,6 +75,7 @@ func (l *LoggerTLS) ProcessLogs(data json.RawMessage, logType, environment, ipad } // Dispatch logs and update metadata l.DispatchLogs(data, uuid, logType, environment, metadata, debug) + return resultLogs } // ProcessLogQueryResult - Helper to process on-demand query result logs diff --git a/pkg/logging/process_test.go b/pkg/logging/process_test.go new file mode 100644 index 00000000..a3ca9576 --- /dev/null +++ b/pkg/logging/process_test.go @@ -0,0 +1,36 @@ +package logging + +import ( + "encoding/json" + "testing" +) + +func TestParseResultLogsPreservesPostureColumns(t *testing.T) { + data := json.RawMessage(`[{"name":"osctrl:posture:users","columns":[{"username":"alice"}],"hostIdentifier":"NODE-A","decorations":{"hostname":"host-a"}}]`) + + logs, err := parseResultLogs(data) + if err != nil { + t.Fatalf("parse result logs: %v", err) + } + if len(logs) != 1 { + t.Fatalf("expected one result, got %d", len(logs)) + } + if logs[0].Name != "osctrl:posture:users" || string(logs[0].Columns) != `[{"username":"alice"}]` { + t.Fatalf("result data was not preserved: %+v", logs[0]) + } +} + +func TestParseResultLogsUsesSnapshotAsColumnsForSnapshotResults(t *testing.T) { + data := json.RawMessage(`[{"name":"osctrl:posture:listening_ports","action":"snapshot","snapshot":[{"address":"127.0.0.11","port":"35279"}],"hostIdentifier":"NODE-A","decorations":{"hostname":"host-a"}}]`) + + logs, err := parseResultLogs(data) + if err != nil { + t.Fatalf("parse result logs: %v", err) + } + if len(logs) != 1 { + t.Fatalf("expected one result, got %d", len(logs)) + } + if string(logs[0].Columns) != `[{"address":"127.0.0.11","port":"35279"}]` { + t.Fatalf("snapshot data was not normalized into columns: %+v", logs[0]) + } +} diff --git a/pkg/posture/posture.go b/pkg/posture/posture.go new file mode 100644 index 00000000..8a884659 --- /dev/null +++ b/pkg/posture/posture.go @@ -0,0 +1,286 @@ +package posture + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/rs/zerolog/log" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// DefaultQueryPrefix identifies scheduled queries whose result logs should be +// ingested as posture data by default. +// +// Recommended practice: set the osquery schedule interval for posture +// queries to once per day (86400 seconds). Posture data — installed +// packages, users, disk encryption state — changes infrequently, and +// daily collection is sufficient for compliance audits without +// overloading agents or the logging pipeline. +const DefaultQueryPrefix = "osctrl:posture:" + +// QueryPrefix is the active posture query prefix. It is configured once at TLS +// startup. Empty disables posture ingestion. +var QueryPrefix = DefaultQueryPrefix + +// SetPrefix sets the active posture query prefix. +func SetPrefix(prefix string) { + QueryPrefix = prefix +} + +// IsPostureQuery returns true if the query name starts with the posture +// prefix. +func IsPostureQuery(name string) bool { + return QueryPrefix != "" && strings.HasPrefix(name, QueryPrefix) +} + +// PostureCategory extracts the category name from a posture query name. +// e.g. "osctrl:posture:packages" → "packages" +func PostureCategory(name string) string { + if !IsPostureQuery(name) { + return "" + } + return strings.TrimPrefix(name, QueryPrefix) +} + +// NodePosture stores the latest snapshot of a single posture category +// for one node. Updated (upserted) every time a matching result log arrives. +type NodePosture struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + NodeUUID string `gorm:"type:varchar(36);uniqueIndex:idx_posture_node_category" json:"node_uuid"` + Environment string `gorm:"index:idx_posture_env" json:"environment"` + Category string `gorm:"uniqueIndex:idx_posture_node_category;type:varchar(64)" json:"category"` + // QueryName is the full osquery scheduled-query name including prefix. + QueryName string `gorm:"type:varchar(255)" json:"query_name"` + // RowCount is the number of rows in the result (e.g. 3 packages, 5 users). + RowCount int `json:"row_count"` + // Summary is a compact JSON snapshot of the result data. For small + // results this is the full columns array; for large results it's + // truncated to the first 100 rows to keep the record manageable. + Summary string `gorm:"type:text" json:"summary"` + // Snapshot is the raw columns JSON from the latest result, capped at + // 256KB. Used for detail views and compatibility with existing posture data. + Snapshot string `gorm:"type:text" json:"-"` + // FirstSeen is when this category was first observed for this node. + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `gorm:"index" json:"last_seen"` +} + +// TableName overrides the default table name. +func (NodePosture) TableName() string { return "node_posture" } + +// PostureManager manages the node posture table. +type PostureManager struct { + DB *gorm.DB +} + +// NewPostureManager creates the manager and auto-migrates the table. +func NewPostureManager(db *gorm.DB) *PostureManager { + pm := &PostureManager{DB: db} + if err := migrateNodePosture(db); err != nil { + log.Fatal().Err(err).Msg("Failed to AutoMigrate table (node_posture)") + } + return pm +} + +const postureMigrationAttempts = 5 + +// migrateNodePosture removes duplicates allowed by the original schema before +// adding the unique node/category index. Retrying makes concurrent API/TLS +// startup idempotent if another instance changes the table between cleanup and +// index creation. +func migrateNodePosture(db *gorm.DB) error { + var lastErr error + for attempt := 0; attempt < postureMigrationAttempts; attempt++ { + if db.Migrator().HasTable(&NodePosture{}) && !db.Migrator().HasIndex(&NodePosture{}, "idx_posture_node_category") { + if err := deduplicateNodePosture(db); err != nil { + lastErr = err + continue + } + } + if err := db.AutoMigrate(&NodePosture{}); err != nil { + lastErr = fmt.Errorf("auto-migrate posture table: %w", err) + continue + } + if db.Migrator().HasIndex(&NodePosture{}, "idx_posture_node") { + if err := db.Migrator().DropIndex(&NodePosture{}, "idx_posture_node"); err != nil && db.Migrator().HasIndex(&NodePosture{}, "idx_posture_node") { + lastErr = fmt.Errorf("drop legacy posture index: %w", err) + continue + } + } + return nil + } + return fmt.Errorf("migrate posture table after %d attempts: %w", postureMigrationAttempts, lastErr) +} + +func deduplicateNodePosture(db *gorm.DB) error { + type postureKey struct { + NodeUUID string + Category string + } + type postureKeeper struct { + ID uint + UpdatedAt time.Time + } + + for { + var keys []postureKey + if err := db.Model(&NodePosture{}). + Select("node_uuid", "category"). + Group("node_uuid, category"). + Having("COUNT(*) > 1"). + Limit(100). + Find(&keys).Error; err != nil { + return fmt.Errorf("find duplicate posture groups: %w", err) + } + if len(keys) == 0 { + return nil + } + + for _, key := range keys { + var keeper postureKeeper + if err := db.Model(&NodePosture{}). + Select("id", "updated_at"). + Where("node_uuid = ? AND category = ?", key.NodeUUID, key.Category). + Order("updated_at DESC, id DESC"). + First(&keeper).Error; err != nil { + return fmt.Errorf("select posture row to retain: %w", err) + } + if err := db.Unscoped(). + Where("node_uuid = ? AND category = ?", key.NodeUUID, key.Category). + Where("updated_at < ? OR (updated_at = ? AND id < ?)", keeper.UpdatedAt, keeper.UpdatedAt, keeper.ID). + Delete(&NodePosture{}).Error; err != nil { + return fmt.Errorf("remove duplicate posture rows: %w", err) + } + } + } +} + +// IngestResult processes a single result log entry and upserts the +// posture record if the query name matches the posture prefix. +func (pm *PostureManager) IngestResult(nodeUUID, environment, queryName string, columns json.RawMessage) error { + if pm == nil { + return fmt.Errorf("posture manager is nil") + } + if !IsPostureQuery(queryName) { + return fmt.Errorf("query %q is not a posture query", queryName) + } + category := PostureCategory(queryName) + + trimmed := bytes.TrimSpace(columns) + rows := []json.RawMessage{} + if len(trimmed) > 0 { + switch trimmed[0] { + case '[': + if err := json.Unmarshal(trimmed, &rows); err != nil { + return fmt.Errorf("parse posture rows: %w", err) + } + if rows == nil { + rows = []json.RawMessage{} + } + case '{': + if !json.Valid(trimmed) { + return fmt.Errorf("parse posture row: invalid JSON") + } + rows = []json.RawMessage{json.RawMessage(trimmed)} + default: + return fmt.Errorf("posture columns must be a JSON object or array") + } + } + for i, row := range rows { + row = bytes.TrimSpace(row) + if len(row) == 0 || row[0] != '{' || !json.Valid(row) { + return fmt.Errorf("posture row %d must be a JSON object", i) + } + rows[i] = row + } + rowCount := len(rows) + + summaryRows := rows + if len(summaryRows) > 100 { + summaryRows = summaryRows[:100] + } + if summaryRows == nil { + summaryRows = []json.RawMessage{} + } + summaryJSON, err := json.Marshal(summaryRows) + if err != nil { + return fmt.Errorf("marshal posture summary: %w", err) + } + snapshot := string(trimmed) + if snapshot == "" { + snapshot = "[]" + } + if len(snapshot) > 256*1024 { + snapshot = snapshot[:256*1024] + } + + uuid := strings.ToUpper(nodeUUID) + now := time.Now() + record := NodePosture{ + NodeUUID: uuid, + Environment: environment, + Category: category, + QueryName: queryName, + RowCount: rowCount, + Summary: string(summaryJSON), + Snapshot: snapshot, + FirstSeen: now, + LastSeen: now, + } + return pm.DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "node_uuid"}, {Name: "category"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "environment": environment, + "query_name": queryName, + "row_count": rowCount, + "summary": string(summaryJSON), + "snapshot": snapshot, + "last_seen": now, + "updated_at": now, + }), + }).Create(&record).Error +} + +// GetByNode returns all posture categories for a node, ordered by category. +func (pm *PostureManager) GetByNode(nodeUUID string) ([]NodePosture, error) { + var records []NodePosture + err := pm.DB.Where("node_uuid = ?", strings.ToUpper(nodeUUID)). + Order("category ASC").Find(&records).Error + return records, err +} + +// GetByNodeCategory returns a single posture category for a node. +func (pm *PostureManager) GetByNodeCategory(nodeUUID, category string) (*NodePosture, error) { + var record NodePosture + err := pm.DB.Where("node_uuid = ? AND category = ?", strings.ToUpper(nodeUUID), category). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +// FleetCategorySummary is a per-category summary across all nodes in an environment. +type FleetCategorySummary struct { + Category string `json:"category"` + NodeCount int `json:"node_count"` + TotalRows int `json:"total_rows"` +} + +// GetFleetSummary returns per-category posture counts across all nodes in an environment. +func (pm *PostureManager) GetFleetSummary(environment string) ([]FleetCategorySummary, error) { + var results []FleetCategorySummary + err := pm.DB.Model(&NodePosture{}). + Select("category, COUNT(*) as node_count, SUM(row_count) as total_rows"). + Where("environment = ?", environment). + Group("category"). + Order("category ASC"). + Scan(&results).Error + return results, err +} diff --git a/pkg/posture/posture_test.go b/pkg/posture/posture_test.go new file mode 100644 index 00000000..9f0971bb --- /dev/null +++ b/pkg/posture/posture_test.go @@ -0,0 +1,307 @@ +package posture + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sync" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newTestManager(t *testing.T) *PostureManager { + t.Helper() + dsn := "file:posture_" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db.DB: %v", err) + } + sqlDB.SetMaxOpenConns(1) + return NewPostureManager(db) +} + +func marshalRows(t *testing.T, count int) json.RawMessage { + t.Helper() + rows := make([]map[string]interface{}, count) + for i := range rows { + rows[i] = map[string]interface{}{"id": i, "name": fmt.Sprintf("row-%d", i)} + } + b, err := json.Marshal(rows) + if err != nil { + t.Fatalf("marshal rows: %v", err) + } + return b +} + +func TestIngestResultTruncatesSummaryWithoutLosingRowCount(t *testing.T) { + pm := newTestManager(t) + if err := pm.IngestResult("node-a", "dev", QueryPrefix+"packages", marshalRows(t, 105)); err != nil { + t.Fatalf("ingest: %v", err) + } + + records, err := pm.GetByNode("node-a") + if err != nil { + t.Fatalf("get posture: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + if records[0].RowCount != 105 { + t.Fatalf("expected row count 105, got %d", records[0].RowCount) + } + var summary []json.RawMessage + if err := json.Unmarshal([]byte(records[0].Summary), &summary); err != nil { + t.Fatalf("parse summary: %v", err) + } + if len(summary) != 100 { + t.Fatalf("expected 100 summary rows, got %d", len(summary)) + } +} + +func TestIngestResultStoresEmptyColumnsAsEmptySnapshot(t *testing.T) { + pm := newTestManager(t) + if err := pm.IngestResult("node-a", "dev", QueryPrefix+"empty", nil); err != nil { + t.Fatalf("ingest empty snapshot: %v", err) + } + + records, err := pm.GetByNode("node-a") + if err != nil { + t.Fatalf("get posture: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + if records[0].RowCount != 0 { + t.Fatalf("expected row count 0, got %d", records[0].RowCount) + } + if records[0].Summary != "[]" { + t.Fatalf("expected empty JSON array summary, got %q", records[0].Summary) + } +} + +func TestIngestResultAtomicallyUpsertsOneRecord(t *testing.T) { + pm := newTestManager(t) + const workers = 8 + start := make(chan struct{}) + errs := make(chan error, workers) + payloads := make([]json.RawMessage, workers) + for i := range payloads { + payloads[i] = marshalRows(t, i+1) + } + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + i := i + go func() { + defer wg.Done() + <-start + errs <- pm.IngestResult("node-a", "env", QueryPrefix+"users", payloads[i]) + }() + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("concurrent ingest: %v", err) + } + } + + var count int64 + if err := pm.DB.Model(&NodePosture{}).Where("node_uuid = ? AND category = ?", "NODE-A", "users").Count(&count).Error; err != nil { + t.Fatalf("count posture rows: %v", err) + } + if count != 1 { + t.Fatalf("expected one node/category row, got %d", count) + } +} + +func TestIngestResultUpdatesMutableFieldsAndPreservesFirstSeen(t *testing.T) { + pm := newTestManager(t) + name := QueryPrefix + "users" + if err := pm.IngestResult("node-a", "old-env", name, marshalRows(t, 1)); err != nil { + t.Fatalf("first ingest: %v", err) + } + before, err := pm.GetByNode("node-a") + if err != nil || len(before) != 1 { + t.Fatalf("get initial posture: records=%d err=%v", len(before), err) + } + time.Sleep(time.Millisecond) + if err := pm.IngestResult("node-a", "new-env", name, marshalRows(t, 2)); err != nil { + t.Fatalf("second ingest: %v", err) + } + after, err := pm.GetByNode("node-a") + if err != nil || len(after) != 1 { + t.Fatalf("get updated posture: records=%d err=%v", len(after), err) + } + if after[0].Environment != "new-env" || after[0].RowCount != 2 || after[0].QueryName != name { + t.Fatalf("mutable fields not updated: %+v", after[0]) + } + if !after[0].FirstSeen.Equal(before[0].FirstSeen) { + t.Fatalf("first_seen changed from %s to %s", before[0].FirstSeen, after[0].FirstSeen) + } + if !after[0].LastSeen.After(before[0].LastSeen) { + t.Fatalf("last_seen did not advance from %s to %s", before[0].LastSeen, after[0].LastSeen) + } +} + +func TestIngestResultRejectsMalformedColumns(t *testing.T) { + pm := newTestManager(t) + for _, columns := range []json.RawMessage{ + json.RawMessage(`{"broken"`), + json.RawMessage(`[null]`), + json.RawMessage(`[1]`), + json.RawMessage(`["row"]`), + json.RawMessage(`[[]]`), + } { + if err := pm.IngestResult("node-a", "dev", QueryPrefix+"users", columns); err == nil { + t.Errorf("expected malformed columns error for %s", columns) + } + } +} + +type legacyNodePosture struct { + ID uint `gorm:"primarykey"` + CreatedAt time.Time + UpdatedAt time.Time + NodeUUID string `gorm:"index:idx_posture_node,composite"` + Environment string + Category string `gorm:"index:idx_posture_node,composite;type:varchar(64)"` + QueryName string `gorm:"type:varchar(255)"` + RowCount int + Summary string `gorm:"type:text"` + FirstSeen time.Time + LastSeen time.Time +} + +func (legacyNodePosture) TableName() string { return "node_posture" } + +func TestMigrateNodePostureDeduplicatesLegacyRowsBeforeUniqueIndex(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:posture_migration?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&legacyNodePosture{}); err != nil { + t.Fatalf("migrate legacy schema: %v", err) + } + oldTime := time.Now().Add(-time.Hour) + newTime := time.Now() + rows := []legacyNodePosture{ + {NodeUUID: "NODE-A", Category: "users", Summary: `[{"old":true}]`, UpdatedAt: oldTime}, + {NodeUUID: "NODE-A", Category: "users", Summary: `[{"new":true}]`, UpdatedAt: newTime}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("insert legacy duplicates: %v", err) + } + + if err := migrateNodePosture(db); err != nil { + t.Fatalf("migrate posture table: %v", err) + } + var retained []NodePosture + if err := db.Find(&retained).Error; err != nil { + t.Fatalf("read migrated rows: %v", err) + } + if len(retained) != 1 || retained[0].Summary != `[{"new":true}]` { + t.Fatalf("expected newest duplicate to survive, got %+v", retained) + } + duplicate := NodePosture{NodeUUID: "NODE-A", Category: "users"} + if err := db.Create(&duplicate).Error; err == nil { + t.Fatal("expected unique node/category index after migration") + } + if db.Migrator().HasIndex(&NodePosture{}, "idx_posture_node") { + t.Fatal("legacy duplicate index still exists after migration") + } +} + +func TestNodePostureUUIDColumnIsBoundedForIndexedBackends(t *testing.T) { + pm := newTestManager(t) + typ, err := pm.DB.Migrator().ColumnTypes(&NodePosture{}) + if err != nil { + t.Fatalf("read posture column metadata: %v", err) + } + for _, column := range typ { + if column.Name() != "node_uuid" { + continue + } + length, ok := column.Length() + if !ok || length != 36 { + t.Fatalf("node_uuid column length = %d, %v; want 36, true", length, ok) + } + return + } + t.Fatal("node_uuid column not found") +} + +func TestMigrateNodePostureHandlesMoreDuplicatesThanSQLBindLimit(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:posture_large_migration?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&legacyNodePosture{}); err != nil { + t.Fatalf("migrate legacy schema: %v", err) + } + rows := make([]legacyNodePosture, 33_000) + for i := range rows { + rows[i] = legacyNodePosture{NodeUUID: "NODE-A", Category: "users", UpdatedAt: time.Unix(int64(i), 0)} + } + if err := db.CreateInBatches(rows, 100).Error; err != nil { + t.Fatalf("insert legacy duplicates: %v", err) + } + + if err := migrateNodePosture(db); err != nil { + t.Fatalf("migrate posture table: %v", err) + } + var count int64 + if err := db.Model(&NodePosture{}).Count(&count).Error; err != nil { + t.Fatalf("count migrated rows: %v", err) + } + if count != 1 { + t.Fatalf("expected one retained row, got %d", count) + } +} + +func TestMigrateNodePostureIsIdempotentDuringConcurrentStartup(t *testing.T) { + dsn := "file:" + filepath.Join(t.TempDir(), "posture.db") + "?_busy_timeout=5000&_journal_mode=WAL" + db1, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open first sqlite connection: %v", err) + } + db2, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open second sqlite connection: %v", err) + } + if err := db1.AutoMigrate(&legacyNodePosture{}); err != nil { + t.Fatalf("migrate legacy schema: %v", err) + } + if err := db1.Create(&[]legacyNodePosture{ + {NodeUUID: "NODE-A", Category: "users", UpdatedAt: time.Now().Add(-time.Minute)}, + {NodeUUID: "NODE-A", Category: "users", UpdatedAt: time.Now()}, + }).Error; err != nil { + t.Fatalf("insert legacy duplicates: %v", err) + } + + start := make(chan struct{}) + errs := make(chan error, 2) + for _, db := range []*gorm.DB{db1, db2} { + go func(db *gorm.DB) { + <-start + errs <- migrateNodePosture(db) + }(db) + } + close(start) + for range 2 { + if err := <-errs; err != nil { + t.Errorf("concurrent migration: %v", err) + } + } + if !db1.Migrator().HasIndex(&NodePosture{}, "idx_posture_node_category") { + t.Fatal("unique index missing after concurrent migration") + } +} diff --git a/pkg/posture/templates.go b/pkg/posture/templates.go new file mode 100644 index 00000000..6523c9f1 --- /dev/null +++ b/pkg/posture/templates.go @@ -0,0 +1,331 @@ +package posture + +import ( + "encoding/json" + "sort" +) + +// PostureProfile is a named set of posture check queries that an operator +// can merge into an environment's schedule config. Each profile targets a +// specific node type and platform with checks tailored to that +// environment's compliance requirements. +type PostureProfile struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + Queries map[string]ProfileQuery `json:"queries"` +} + +// ProfileQuery is a single scheduled query within a posture profile. +type ProfileQuery struct { + Query string `json:"query"` + Interval int `json:"interval"` + Platform string `json:"platform,omitempty"` + Snapshot bool `json:"snapshot"` +} + +// ToScheduleEntries converts a profile's queries into a JSON map suitable +// for merging into an environment's schedule config section. +func (p PostureProfile) ToScheduleEntries() (map[string]map[string]interface{}, error) { + out := make(map[string]map[string]interface{}, len(p.Queries)) + for name, q := range p.Queries { + entry := map[string]interface{}{ + "query": q.Query, + "interval": q.Interval, + "snapshot": q.Snapshot, + } + if q.Platform != "" { + entry["platform"] = q.Platform + } + out[QueryPrefix+name] = entry + } + return out, nil +} + +// ToScheduleJSON returns the schedule entries as pretty-printed JSON. +func (p PostureProfile) ToScheduleJSON() (string, error) { + entries, err := p.ToScheduleEntries() + if err != nil { + return "", err + } + b, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return "", err + } + return string(b), nil +} + +// AllProfiles returns every predefined posture profile, sorted by name. +func AllProfiles() []PostureProfile { + profiles := []PostureProfile{ + WindowsServerProfile(), + LinuxServerProfile(), + MacOSLaptopProfile(), + WindowsLaptopProfile(), + LinuxLaptopProfile(), + } + sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name }) + return profiles +} + +// GetProfile returns a single profile by ID, or nil if not found. +func GetProfile(id string) *PostureProfile { + for _, profile := range AllProfiles() { + if profile.ID == id { + return &profile + } + } + return nil +} + +// ----------------------------------------------------------------------- +// Windows Servers +// ----------------------------------------------------------------------- + +func WindowsServerProfile() PostureProfile { + return PostureProfile{ + ID: "win-server", + Name: "Windows Servers", + Platform: "windows", + Description: "Posture checks for Windows production servers: installed programs, real users, disk encryption, running services, scheduled tasks, autorun entries, listening ports, and patches. All queries run once per day.", + Queries: map[string]ProfileQuery{ + "packages_windows": { + Query: "SELECT name, version, publisher, install_date FROM programs ORDER BY name", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "users": { + Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", + Interval: 86400, Snapshot: true, + }, + "disk_encryption": { + Query: "SELECT drive_letter, protection_status, encryption_method, percentage_encrypted, lock_status FROM bitlocker_info ORDER BY drive_letter", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "listening_ports": { + Query: "SELECT pid, port, address, family, path FROM listening_ports WHERE port != 0 ORDER BY port", + Interval: 86400, Snapshot: true, + }, + "windows_services": { + Query: "SELECT name, display_name, status, start_type, path FROM services WHERE status = 'RUNNING' ORDER BY name", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "windows_scheduled_tasks": { + Query: "SELECT name, state, action, path, enabled FROM scheduled_tasks WHERE enabled = 1 ORDER BY name", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "autorun": { + Query: "SELECT name, path, source FROM autoexec ORDER BY name", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "patches": { + Query: "SELECT hotfix_id, installed_on, description FROM patches ORDER BY installed_on DESC", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + }, + } +} + +// ----------------------------------------------------------------------- +// Linux Servers +// ----------------------------------------------------------------------- + +func LinuxServerProfile() PostureProfile { + return PostureProfile{ + ID: "linux-server", + Name: "Linux Servers", + Platform: "linux", + Description: "Posture checks for Linux production servers: deb/rpm packages, real users, disk encryption, listening ports, cron jobs, systemd timers, kernel modules, SUID binaries, and SSH keys. All queries run once per day.", + Queries: map[string]ProfileQuery{ + "packages_deb": { + Query: "SELECT name, version, revision, source AS repo FROM deb_packages ORDER BY name", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "packages_rpm": { + Query: "SELECT name, version, release, '' AS repo FROM rpm_packages ORDER BY name", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "users": { + Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", + Interval: 86400, Snapshot: true, + }, + "disk_encryption": { + Query: "SELECT name, encrypted, type FROM disk_encryption WHERE name IS NOT NULL", + Interval: 86400, Snapshot: true, + }, + "listening_ports": { + Query: "SELECT pid, port, address, family, path FROM listening_ports WHERE port != 0 ORDER BY port", + Interval: 86400, Snapshot: true, + }, + "cron_jobs": { + Query: "SELECT event, minute, hour, day_of_month, month, day_of_week, command, path FROM crontab ORDER BY path, event", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "systemd_timers": { + Query: "SELECT id, active_state, sub_state, load_state FROM systemd_units WHERE active_state = 'active' ORDER BY id", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "kernel_modules": { + Query: "SELECT name, size, used_by, status FROM kernel_modules WHERE status = 'Live' ORDER BY name", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "suid_binaries": { + Query: "SELECT path, permissions, username, groupname FROM suid_bin ORDER BY path", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "ssh_keys": { + Query: "SELECT * FROM users CROSS JOIN authorized_keys USING (uid)", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + }, + } +} + +// ----------------------------------------------------------------------- +// macOS Laptops +// ----------------------------------------------------------------------- + +func MacOSLaptopProfile() PostureProfile { + return PostureProfile{ + ID: "macos-laptop", + Name: "macOS Laptops", + Platform: "darwin", + Description: "Posture checks for corporate macOS laptops: Homebrew packages, installed apps, real users, disk encryption, startup items, browser extensions, WiFi networks, SSH keys, and file sharing preferences. All queries run once per day.", + Queries: map[string]ProfileQuery{ + "packages_brew": { + Query: "SELECT name, version, type FROM homebrew_packages ORDER BY name", + Interval: 86400, Platform: "darwin", Snapshot: true, + }, + "packages_apps": { + Query: "SELECT name, bundle_identifier AS bundle_id, bundle_short_version AS version FROM apps ORDER BY name", + Interval: 86400, Platform: "darwin", Snapshot: true, + }, + "users": { + Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", + Interval: 86400, Snapshot: true, + }, + "disk_encryption": { + Query: "SELECT name, encrypted, type FROM disk_encryption WHERE name IS NOT NULL", + Interval: 86400, Snapshot: true, + }, + "startup_items": { + Query: "SELECT path, name, type, status, source FROM startup_items ORDER BY name", + Interval: 86400, Snapshot: true, + }, + "browser_extensions_chrome": { + Query: "SELECT uid, name, identifier, version FROM chrome_extensions ORDER BY uid, name", + Interval: 86400, Snapshot: true, + }, + "browser_extensions_firefox": { + Query: "SELECT uid, name, identifier, version FROM firefox_addons ORDER BY uid, name", + Interval: 86400, Snapshot: true, + }, + "wifi_networks": { + Query: "SELECT ssid, security_type, last_connected, auto_join FROM wifi_networks ORDER BY ssid", + Interval: 86400, Platform: "darwin", Snapshot: true, + }, + "ssh_keys": { + Query: "SELECT * FROM users CROSS JOIN authorized_keys USING (uid)", + Interval: 86400, Snapshot: true, + }, + "file_sharing": { + Query: "SELECT file_sharing, screen_sharing, remote_login, remote_management, printer_sharing, internet_sharing FROM sharing_preferences", + Interval: 86400, Platform: "darwin", Snapshot: true, + }, + }, + } +} + +// ----------------------------------------------------------------------- +// Windows Laptops +// ----------------------------------------------------------------------- + +func WindowsLaptopProfile() PostureProfile { + return PostureProfile{ + ID: "win-laptop", + Name: "Windows Laptops", + Platform: "windows", + Description: "Posture checks for corporate Windows laptops: installed programs, real users, disk encryption, startup items, browser extensions, autorun entries, and patches. All queries run once per day.", + Queries: map[string]ProfileQuery{ + "packages_windows": { + Query: "SELECT name, version, publisher, install_date FROM programs ORDER BY name", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "users": { + Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", + Interval: 86400, Snapshot: true, + }, + "disk_encryption": { + Query: "SELECT drive_letter, protection_status, encryption_method, percentage_encrypted, lock_status FROM bitlocker_info ORDER BY drive_letter", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "startup_items": { + Query: "SELECT path, name, type, status, source FROM startup_items ORDER BY name", + Interval: 86400, Snapshot: true, + }, + "browser_extensions_chrome": { + Query: "SELECT uid, name, identifier, version FROM chrome_extensions ORDER BY uid, name", + Interval: 86400, Snapshot: true, + }, + "browser_extensions_firefox": { + Query: "SELECT uid, name, identifier, version FROM firefox_addons ORDER BY uid, name", + Interval: 86400, Snapshot: true, + }, + "autorun": { + Query: "SELECT name, path, source FROM autoexec ORDER BY name", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + "patches": { + Query: "SELECT hotfix_id, installed_on, description FROM patches ORDER BY installed_on DESC", + Interval: 86400, Platform: "windows", Snapshot: true, + }, + }, + } +} + +// ----------------------------------------------------------------------- +// Linux Laptops +// ----------------------------------------------------------------------- + +func LinuxLaptopProfile() PostureProfile { + return PostureProfile{ + ID: "linux-laptop", + Name: "Linux Laptops", + Platform: "linux", + Description: "Posture checks for corporate Linux laptops: deb/rpm packages, real users, disk encryption, startup items, browser extensions, and SSH keys. All queries run once per day.", + Queries: map[string]ProfileQuery{ + "packages_deb": { + Query: "SELECT name, version, revision, source AS repo FROM deb_packages ORDER BY name", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "packages_rpm": { + Query: "SELECT name, version, release, '' AS repo FROM rpm_packages ORDER BY name", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + "users": { + Query: "SELECT username, uid, gid, shell, type FROM users WHERE shell IS NOT NULL AND shell != '' AND shell NOT LIKE '%/nologin' AND shell NOT LIKE '%/false' ORDER BY username", + Interval: 86400, Snapshot: true, + }, + "disk_encryption": { + Query: "SELECT name, encrypted, type FROM disk_encryption WHERE name IS NOT NULL", + Interval: 86400, Snapshot: true, + }, + "startup_items": { + Query: "SELECT path, name, type, status, source FROM startup_items ORDER BY name", + Interval: 86400, Snapshot: true, + }, + "browser_extensions_chrome": { + Query: "SELECT uid, name, identifier, version FROM chrome_extensions ORDER BY uid, name", + Interval: 86400, Snapshot: true, + }, + "browser_extensions_firefox": { + Query: "SELECT uid, name, identifier, version FROM firefox_addons ORDER BY uid, name", + Interval: 86400, Snapshot: true, + }, + "ssh_keys": { + Query: "SELECT * FROM users CROSS JOIN authorized_keys USING (uid)", + Interval: 86400, Platform: "linux", Snapshot: true, + }, + }, + } +} diff --git a/pkg/posture/templates_test.go b/pkg/posture/templates_test.go new file mode 100644 index 00000000..06c166da --- /dev/null +++ b/pkg/posture/templates_test.go @@ -0,0 +1,109 @@ +package posture + +import ( + "encoding/json" + "os" + "regexp" + "slices" + "strings" + "testing" +) + +func TestQueryPrefixDefaultsToOsctrlPosture(t *testing.T) { + if QueryPrefix != DefaultQueryPrefix { + t.Fatalf("unexpected posture query prefix %q", QueryPrefix) + } + if !IsPostureQuery("osctrl:posture:packages") { + t.Fatal("default posture prefix was not recognized") + } +} + +func TestQueryPrefixCanBeConfigured(t *testing.T) { + SetPrefix("custom:") + t.Cleanup(func() { SetPrefix(DefaultQueryPrefix) }) + + if !IsPostureQuery("custom:packages") { + t.Fatal("custom posture prefix was not recognized") + } + if IsPostureQuery("osctrl:posture:packages") { + t.Fatal("default posture prefix matched after custom prefix was configured") + } + if PostureCategory("custom:packages") != "packages" { + t.Fatalf("unexpected custom posture category %q", PostureCategory("custom:packages")) + } +} + +func TestEmptyQueryPrefixDisablesPostureIngestion(t *testing.T) { + SetPrefix("") + t.Cleanup(func() { SetPrefix(DefaultQueryPrefix) }) + + if IsPostureQuery("osctrl:posture:packages") { + t.Fatal("empty posture prefix should disable posture query matching") + } +} + +func TestWindowsProfilesUseSupportedEncryptionTable(t *testing.T) { + for _, profile := range []PostureProfile{WindowsServerProfile(), WindowsLaptopProfile()} { + query, ok := profile.Queries["disk_encryption"] + if !ok { + t.Fatalf("%s has no disk encryption query", profile.ID) + } + if !strings.Contains(query.Query, "bitlocker_info") { + t.Errorf("%s disk encryption query does not use bitlocker_info: %s", profile.ID, query.Query) + } + } +} + +func TestWindowsProfilesDoNotUseDarwinWiFiTable(t *testing.T) { + if _, ok := WindowsLaptopProfile().Queries["wifi_networks"]; ok { + t.Fatal("Windows laptop profile includes the Darwin-only wifi_networks table") + } +} + +func TestWindowsServicesUseOsqueryStatusValue(t *testing.T) { + query := WindowsServerProfile().Queries["windows_services"].Query + if !strings.Contains(query, "status = 'RUNNING'") { + t.Fatalf("Windows services query uses an unsupported status value: %s", query) + } +} + +func TestProfileTablesSupportTargetPlatform(t *testing.T) { + type schemaTable struct { + Name string `json:"name"` + Platforms []string `json:"platforms"` + } + + data, err := os.ReadFile("../../deploy/osquery/data/5.23.1.json") + if err != nil { + t.Fatalf("read bundled osquery schema: %v", err) + } + var schema []schemaTable + if err := json.Unmarshal(data, &schema); err != nil { + t.Fatalf("parse bundled osquery schema: %v", err) + } + tables := make(map[string][]string, len(schema)) + for _, table := range schema { + tables[table.Name] = table.Platforms + } + + tablePattern := regexp.MustCompile(`(?i)\b(?:FROM|JOIN)\s+([a-z_]+)`) + for _, profile := range AllProfiles() { + for name, query := range profile.Queries { + matches := tablePattern.FindAllStringSubmatch(query.Query, -1) + if len(matches) == 0 { + t.Errorf("%s/%s references no table", profile.ID, name) + continue + } + for _, match := range matches { + platforms, ok := tables[match[1]] + if !ok { + t.Errorf("%s/%s references unknown table %s", profile.ID, name, match[1]) + continue + } + if !slices.Contains(platforms, profile.Platform) { + t.Errorf("%s/%s uses table %s on %s; supported platforms: %v", profile.ID, name, match[1], profile.Platform, platforms) + } + } + } + } +} diff --git a/pkg/types/osquery.go b/pkg/types/osquery.go index c4ea8166..d2d2b064 100644 --- a/pkg/types/osquery.go +++ b/pkg/types/osquery.go @@ -153,6 +153,7 @@ type LogResultData struct { Epoch int64 `json:"epoch"` Action string `json:"action"` Columns json.RawMessage `json:"columns"` + Snapshot json.RawMessage `json:"snapshot"` Counter int `json:"counter"` UnixTime StringInt `json:"unixTime"` Decorations LogDecorations `json:"decorations"`