From 1bb6ad2971641e47879065b800babb3a0c4ab939 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:13:58 +0200 Subject: [PATCH] Posture checks stored in backend --- cmd/api/handlers/nodes.go | 26 +- cmd/api/main.go | 2 +- cmd/cli/environment.go | 6 +- frontend/src/api/types.ts | 1 + .../environments/EnvConfigPage.test.tsx | 157 ++++++++ .../features/environments/EnvConfigPage.tsx | 332 ++++++++++++++++- .../environments/postureSchedule.test.ts | 24 ++ .../features/environments/postureSchedule.ts | 3 +- osctrl-api.yaml | 49 +++ pkg/posture/checks.go | 336 ++++++++++++++++++ pkg/posture/posture.go | 28 +- pkg/posture/posture_test.go | 87 +++++ pkg/posture/scoring.go | 91 ++++- pkg/posture/templates.go | 22 +- 14 files changed, 1133 insertions(+), 31 deletions(-) create mode 100644 pkg/posture/checks.go diff --git a/cmd/api/handlers/nodes.go b/cmd/api/handlers/nodes.go index 09edf79a..bf481c25 100644 --- a/cmd/api/handlers/nodes.go +++ b/cmd/api/handlers/nodes.go @@ -697,7 +697,16 @@ func (h *HandlersApi) PostureProfilesHandler(w http.ResponseWriter, r *http.Requ apiErrorResponse(w, "missing auth context", http.StatusUnauthorized, nil) return } - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, posture.AllProfiles()) + if h.Posture == nil { + apiErrorResponse(w, "posture not configured", http.StatusServiceUnavailable, nil) + return + } + profiles, err := h.Posture.AllProfiles() + if err != nil { + apiErrorResponse(w, "error getting posture profiles", http.StatusInternalServerError, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, profiles) } // PostureProfileHandler — GET /api/v1/posture/profiles/{id} @@ -723,8 +732,12 @@ func (h *HandlersApi) PostureProfileHandler(w http.ResponseWriter, r *http.Reque apiErrorResponse(w, "profile id required", http.StatusBadRequest, nil) return } - profile := posture.GetProfile(profileID) - if profile == nil { + if h.Posture == nil { + apiErrorResponse(w, "posture not configured", http.StatusServiceUnavailable, nil) + return + } + profile, err := h.Posture.GetProfile(profileID) + if err != nil { apiErrorResponse(w, "profile not found", http.StatusNotFound, nil) return } @@ -854,7 +867,10 @@ func (h *HandlersApi) NodePostureScoreHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error getting posture", http.StatusInternalServerError, err) return } - calculator := posture.NewScoreCalculator() - score := calculator.Score(records) + score, err := h.Posture.Score(records) + if err != nil { + apiErrorResponse(w, "error scoring posture", http.StatusInternalServerError, err) + return + } utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, score) } diff --git a/cmd/api/main.go b/cmd/api/main.go index 0ff38dd1..2531649a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -107,7 +107,7 @@ const ( // API service config path apiServiceConfigPath = "/service-config" // API log sinks path - apiLogSinksPath = "/log-sinks" + apiLogSinksPath = "/log-sinks" apiAuthProvidersPath = "/auth-providers" // API features path apiFeaturesPath = "/features" diff --git a/cmd/cli/environment.go b/cmd/cli/environment.go index 29bbf975..7cfed91f 100644 --- a/cmd/cli/environment.go +++ b/cmd/cli/environment.go @@ -813,7 +813,11 @@ func postureProfileScheduleQueries(profileID, prefix string, intervalOverride in if intervalOverride > 0 { interval = intervalOverride } - schedule[prefix+name] = environments.ScheduleQuery{ + queryName := query.QueryName + if queryName == "" { + queryName = prefix + name + } + schedule[queryName] = environments.ScheduleQuery{ Query: query.Query, Interval: json.Number(strconv.Itoa(interval)), Platform: query.Platform, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7bce7899..a23fd895 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -587,6 +587,7 @@ export interface NodePosture { } export interface ProfileQuery { + query_name?: string; query: string; interval: number; platform?: string; diff --git a/frontend/src/features/environments/EnvConfigPage.test.tsx b/frontend/src/features/environments/EnvConfigPage.test.tsx index e5d020d1..bd6ff96c 100644 --- a/frontend/src/features/environments/EnvConfigPage.test.tsx +++ b/frontend/src/features/environments/EnvConfigPage.test.tsx @@ -264,6 +264,163 @@ describe('EnvConfigPage', () => { await user.click(await screen.findByRole('tab', { name: 'Schedule' })); expect(screen.queryByRole('button', { name: 'Add posture checks' })).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Posture' })).not.toBeInTheDocument(); expect(mockGetPostureProfiles).not.toHaveBeenCalled(); }); + + it('adds a posture profile from the schedule picker into the posture tab', async () => { + const user = userEvent.setup(); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); + mockGetPostureProfiles.mockResolvedValue([ + { + id: 'linux-server', + name: 'Linux Servers', + description: 'Linux host posture checks', + platform: 'linux', + queries: { + users: { + query: 'SELECT username FROM users', + interval: 86400, + snapshot: true, + }, + }, + }, + ]); + + renderWithProviders(); + + await user.click(await screen.findByRole('tab', { name: 'Schedule' })); + await user.click(screen.getByRole('button', { name: 'Add posture checks' })); + + expect(await screen.findByText('linux')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add Linux Servers to schedule' })); + await user.click(screen.getByRole('tab', { name: 'Posture' })); + + expect(screen.getByDisplayValue('osctrl:posture:users')).toBeInTheDocument(); + expect(screen.getByLabelText('Profile')).toHaveValue('linux-server'); + }); + + it('saves the selected posture profile for a new manual check', async () => { + const user = userEvent.setup(); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); + mockGetPostureProfiles.mockResolvedValue([ + { + id: 'linux-server', + name: 'Linux Servers', + description: 'Linux host posture checks', + platform: 'linux', + queries: {}, + }, + ]); + mockPatchConfig.mockImplementation(async (_env, body) => ({ + options: '{}', + schedule: body.schedule, + packs: '{}', + decorators: '{}', + atc: '{}', + flags: '', + })); + + renderWithProviders(); + + await user.click(await screen.findByRole('tab', { name: 'Posture' })); + await user.click(screen.getByRole('button', { name: 'Add check' })); + await user.selectOptions(screen.getByLabelText('Profile'), 'linux-server'); + await user.click(screen.getByRole('button', { name: 'Save posture checks' })); + + await waitFor(() => { + expect(mockPatchConfig).toHaveBeenCalledWith('dev', { + schedule: JSON.stringify({ + 'osctrl:posture:new_check': { + query: 'SELECT 1', + interval: 86400, + platform: 'linux', + snapshot: true, + profile_id: 'linux-server', + }, + }, null, 2), + }); + }); + }); + + it('shows an environment-scoped posture tab when posture is enabled', async () => { + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); + mockGetConfig.mockResolvedValue({ + options: '{}', + schedule: JSON.stringify({ + 'osctrl:posture:users': { + query: 'SELECT username FROM users', + interval: 86400, + snapshot: true, + profile_id: 'linux-server', + }, + }), + packs: '{}', + decorators: '{}', + atc: '{}', + flags: '', + }); + + renderWithProviders(); + + expect(await screen.findByRole('tab', { name: 'Posture' })).toBeInTheDocument(); + }); + + it('hides the posture tab when posture is disabled', async () => { + renderWithProviders(); + + await screen.findByRole('tab', { name: 'Settings' }); + + expect(screen.queryByRole('tab', { name: 'Posture' })).not.toBeInTheDocument(); + }); + + it('edits posture checks through the environment schedule', async () => { + const user = userEvent.setup(); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); + mockGetConfig.mockResolvedValue({ + options: '{}', + schedule: JSON.stringify({ + 'osctrl:posture:users': { + query: 'SELECT username FROM users', + interval: 86400, + snapshot: true, + profile_id: 'linux-server', + }, + }), + packs: '{}', + decorators: '{}', + atc: '{}', + flags: '', + }); + mockPatchConfig.mockImplementation(async (_env, body) => ({ + options: '{}', + schedule: body.schedule, + packs: '{}', + decorators: '{}', + atc: '{}', + flags: '', + })); + + renderWithProviders(); + + await user.click(await screen.findByRole('tab', { name: 'Posture' })); + await user.clear(screen.getByLabelText('Query name')); + await user.type(screen.getByLabelText('Query name'), 'osctrl:posture:interactive_users'); + await user.clear(screen.getByLabelText('Interval')); + await user.type(screen.getByLabelText('Interval'), '3600'); + await user.click(screen.getByRole('button', { name: 'Save posture checks' })); + + await waitFor(() => { + expect(mockPatchConfig).toHaveBeenCalledWith('dev', { + schedule: JSON.stringify({ + 'osctrl:posture:interactive_users': { + query: 'SELECT username FROM users', + interval: 3600, + snapshot: true, + profile_id: 'linux-server', + }, + }, null, 2), + }); + }); + }); }); diff --git a/frontend/src/features/environments/EnvConfigPage.tsx b/frontend/src/features/environments/EnvConfigPage.tsx index 9932ac66..96d38085 100644 --- a/frontend/src/features/environments/EnvConfigPage.tsx +++ b/frontend/src/features/environments/EnvConfigPage.tsx @@ -24,6 +24,14 @@ import { DocsLink } from '$/components/atoms/DocsLink'; import { AssembledConfigCard } from '$/features/enrollment/AssembledConfigCard'; type SectionKey = 'options' | 'schedule' | 'packs' | 'decorators' | 'atc' | 'flags'; +type PostureScheduleEntry = { + profileID: string; + queryName: string; + query: string; + interval: number; + platform: string; + snapshot: boolean; +}; const POSTURE_INTERVALS = [ { label: 'Daily', seconds: 86400, description: 'Once per day — sufficient for compliance' }, @@ -31,6 +39,7 @@ const POSTURE_INTERVALS = [ { label: 'Every 6h', seconds: 21600, description: 'Four times per day — active monitoring' }, { label: 'Every 1h', seconds: 3600, description: 'Hourly — high-frequency monitoring' }, ]; +const POSTURE_QUERY_PREFIX = 'osctrl:posture:'; // docs URLs point at the upstream osquery read-the-docs anchors so an // operator can jump from the section header straight to the canonical @@ -121,6 +130,8 @@ export function EnvConfigPage() { const [saveErr, setSaveErr] = useState(null); const [showPosturePicker, setShowPosturePicker] = useState(false); const [postureAggressiveness, setPostureAggressiveness] = useState(1); + type TabKey = 'settings' | SectionKey | 'posture' | 'assembled'; + const [activeTab, setActiveTab] = useState('settings'); const featuresQuery = useQuery({ queryKey: ['features'], queryFn: () => getFeatures(), @@ -130,7 +141,7 @@ export function EnvConfigPage() { const postureProfilesQuery = useQuery({ queryKey: ['posture-profiles'], queryFn: () => getPostureProfiles(), - enabled: showPosturePicker && postureEnabled, + enabled: (showPosturePicker || activeTab === 'posture') && postureEnabled, staleTime: 5 * 60_000, retry: 1, }); @@ -153,8 +164,6 @@ export function EnvConfigPage() { // config SECTION. The "settings" default keeps the slider-based forms // up-front so an operator who lands here to tune pull intervals doesn't // scroll past six 280px Monaco editors first. - type TabKey = 'settings' | SectionKey | 'assembled'; - const [activeTab, setActiveTab] = useState('settings'); useEffect(() => { if (cfgQuery.data && draft === null) { @@ -341,6 +350,15 @@ export function EnvConfigPage() { onClick={() => setActiveTab(key)} /> ))} + {postureEnabled && ( + setActiveTab('posture')} + /> + )} )} + {activeTab === 'posture' && postureEnabled && ( + saveOne.mutate({ key: 'schedule', value: schedule })} + profiles={postureProfiles ?? []} + /> + )} + {SECTIONS.map(({ key, label, language, help, docsUrl }) => { if (activeTab !== key) return null; const isDirty = dirty.has(key); @@ -495,14 +522,13 @@ export function EnvConfigPage() { {showPosturePicker && (
setShowPosturePicker(false)}> -
e.stopPropagation()}> +
e.stopPropagation()}>

Posture check profiles

- +
- {/* Aggressiveness slider */} -
+
Check frequency @@ -520,7 +546,8 @@ export function EnvConfigPage() { />
{POSTURE_INTERVALS.map((p, i) => ( - setPostureAggressiveness(i + 1)} > {p.label} - + ))}

@@ -539,10 +566,9 @@ export function EnvConfigPage() {

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

Loading posture profiles…

+

Loading posture profiles...

)} {postureProfilesQuery.isError && (
@@ -551,13 +577,13 @@ export function EnvConfigPage() {
)} {(postureProfiles ?? []).map((profile: PostureProfile) => ( -
+
{profile.name} {profile.platform}
- +

{profile.description}

@@ -625,6 +651,286 @@ function TabButton({ ); } +function PostureScheduleEditor({ + schedule, + saving, + onSave, + profiles, +}: { + schedule: string; + saving: boolean; + onSave: (schedule: string) => void; + profiles: PostureProfile[]; +}) { + const parsed = useMemo(() => parsePostureSchedule(schedule, profiles), [schedule, profiles]); + const [rows, setRows] = useState(parsed.entries); + const [error, setError] = useState(parsed.error); + + useEffect(() => { + setRows(parsed.entries); + setError(parsed.error); + }, [parsed]); + + function updateRow(index: number, patch: Partial) { + setRows((current) => current.map((row, i) => (i === index ? { ...row, ...patch } : row))); + } + + function updateProfile(index: number, profileID: string) { + const profile = profiles.find((p) => p.id === profileID); + setRows((current) => current.map((row, i) => ( + i === index + ? { ...row, profileID, platform: profile?.platform || row.platform } + : row + ))); + } + + function saveRows() { + try { + onSave(buildScheduleWithPosture(schedule, rows)); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid posture checks.'); + } + } + + return ( +
+
+

Posture

+

+ Environment posture checks stored in this environment's schedule. +

+ + +
+ + {error && ( +

+ {error} +

+ )} + +
+ {rows.length === 0 && ( +
+ No posture checks in this environment schedule. +
+ )} + {rows.map((row, index) => ( +
+ + +