From ad615a47a0df609daccd1ed7d798681ddd018454 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Tue, 28 Jul 2026 16:21:30 +0200 Subject: [PATCH] Show posture values and report history Pass/fail was the main device UI signal, but operators need the agent's observed value. Expose a formatted value per check, show current postures on the device page, and replace the Postures tab with paginated report history grouped by agent push time. Status stays in the model for later rulesets. Signed-off-by: Ludovic Vielle --- apps/console/src/_locales/en-US.json | 2 +- apps/console/src/_locales/fr-FR.json | 39 +- .../organizations/devices/DeviceLayout.tsx | 31 +- .../organizations/devices/DevicesPage.tsx | 4 - .../_components/DeviceCurrentPostures.tsx | 81 ++ .../devices/_components/DeviceDetailsCard.tsx | 132 +- .../devices/_components/DeviceRow.tsx | 39 +- ...ColumnHeader.tsx => PostureValueBadge.tsx} | 68 +- .../devices/_lib/deviceDisplay.ts | 76 +- .../_lib/getPostureCheckLabel.ts | 0 .../DeviceHistoryPage.tsx} | 24 +- .../DeviceHistoryPageLoader.tsx} | 18 +- .../_components/DevicePostureReportList.tsx | 110 ++ .../DevicePostureReportListItem.tsx | 133 ++ .../_components/DevicePostureList.tsx | 70 -- .../_components/DevicePostureListItem.tsx | 64 - .../postures/_lib/getPostureStatusLabel.ts | 33 - .../src/pages/organizations/devices/routes.ts | 6 +- contrib/seed.sh | 236 +++- e2e/console/device_enrollment_test.go | 400 ++++++ pkg/coredata/device_posture.go | 71 +- pkg/coredata/device_posture_report.go | 145 +++ .../device_posture_report_order_field.go | 84 ++ pkg/coredata/device_posture_report_test.go | 322 +++++ pkg/coredata/device_posture_test.go | 1 + pkg/coredata/device_posture_value.go | 409 +++++++ pkg/coredata/device_posture_value_checks.go | 584 +++++++++ pkg/coredata/device_posture_value_kind.go | 121 ++ pkg/coredata/device_posture_value_test.go | 1070 +++++++++++++++++ pkg/coredata/entity_type_reg.go | 3 + pkg/coredata/migrations/20260728T174416Z.sql | 48 + pkg/deviceagent/agent.go | 22 +- pkg/deviceagent/client.go | 9 +- pkg/itam/service.go | 141 ++- pkg/server/api/agent/v1/agent.go | 34 +- pkg/server/api/agent/v1/types/models.go | 9 +- pkg/server/api/console/v1/device_resolvers.go | 58 +- .../api/console/v1/graphql/device.graphql | 88 ++ pkg/server/api/console/v1/types/device.go | 53 +- 39 files changed, 4439 insertions(+), 399 deletions(-) create mode 100644 apps/console/src/pages/organizations/devices/_components/DeviceCurrentPostures.tsx rename apps/console/src/pages/organizations/devices/_components/{PostureColumnHeader.tsx => PostureValueBadge.tsx} (52%) rename apps/console/src/pages/organizations/devices/{postures => }/_lib/getPostureCheckLabel.ts (100%) rename apps/console/src/pages/organizations/devices/{postures/DevicePosturesPage.tsx => history/DeviceHistoryPage.tsx} (67%) rename apps/console/src/pages/organizations/devices/{postures/DevicePosturesPageLoader.tsx => history/DeviceHistoryPageLoader.tsx} (77%) create mode 100644 apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx create mode 100644 apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportListItem.tsx delete mode 100644 apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx delete mode 100644 apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx delete mode 100644 apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts create mode 100644 pkg/coredata/device_posture_report.go create mode 100644 pkg/coredata/device_posture_report_order_field.go create mode 100644 pkg/coredata/device_posture_report_test.go create mode 100644 pkg/coredata/device_posture_value.go create mode 100644 pkg/coredata/device_posture_value_checks.go create mode 100644 pkg/coredata/device_posture_value_kind.go create mode 100644 pkg/coredata/device_posture_value_test.go create mode 100644 pkg/coredata/migrations/20260728T174416Z.sql diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 53acca3f3..67604c918 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -2485,7 +2485,7 @@ "auth": { "actions": { "signIn": "Sign in" } }, "authError": { "enterpriseAccountRequired": { "title": "Enterprise account required", "description": "Personal Google and Microsoft accounts cannot be used to sign in. Please use your work or school account instead." }, "emailNotVerified": { "title": "Email not verified", "description": "Your email address is not verified with the identity provider. Please verify it, then try signing in again." }, "signInSessionExpired": { "title": "Sign-in session expired", "description": "This sign-in attempt is no longer valid. Please start again from the sign-in page." }, "magicLinkExpired": { "title": "Link Expired", "description": "This magic link has expired. Magic links are only valid for 15 minutes. Please request a new one." }, "magicLinkAlreadyUsed": { "title": "Link Already Used", "description": "This magic link has already been used. Please request a new one." }, "invalidLink": { "title": "Invalid link", "description": "This magic link is invalid. Please request a new one." }, "default": { "title": "Authentication failed", "description": "We could not complete your sign-in. Please try again." } }, "accessReviewSource": { "documentation": "Documentation", "regions": { "label": "Region", "placeholder": "Select a region", "unitedStates": "United States", "europe": "Europe" } }, - "devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled." }, "postures": { "title": "Posture", "navigation": "Postures", "summaryHelp": "How posture summary is calculated", "summaryDescription": "Shown as pass / fail / total.", "empty": "No posture checks recorded", "columns": { "check": "Check", "status": "Status", "observedAt": "Observed at" }, "status": { "pass": "Pass", "fail": "Fail", "unknown": "Unknown", "notApplicable": "Not applicable" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } } }, + "devices": { "title": "Devices", "description": "Manage computers enrolled with the Probo posture agent.", "empty": "No devices enrolled yet", "actions": { "add": "Add device", "new": "New device", "reassign": "Re-assign", "revoke": "Revoke" }, "fields": { "organization": "Organization", "hostname": "Hostname", "owner": "Owner", "state": "State", "platform": "Platform", "osVersion": "OS version", "hardwareUuid": "Hardware UUID", "serialNumber": "Serial number", "agentVersion": "Agent version", "enrolledAt": "Enrolled at", "lastSeen": "Last seen" }, "values": { "pending": "(pending)", "never": "Never", "unassigned": "Unassigned" }, "messages": { "ownerUpdated": "Device owner updated", "revoked": "Device revoked" }, "errors": { "create": "Failed to create device", "reassign": "Failed to re-assign device", "revoke": "Failed to revoke device" }, "confirmations": { "revoke": "Revoke device \"{{hostname}}\"? The agent on the device will stop reporting and must be re-enrolled." }, "postures": { "currentTitle": "Current postures", "empty": "No posture checks recorded", "values": { "on": "On", "off": "Off", "immediate": "Immediate", "seconds": "{{seconds}}s", "minPasswordLength": "Min length {{length}}", "configured": "Configured", "none": "None", "unknown": "Unknown" }, "checks": { "diskEncryption": "Disk encryption", "screenLock": "Screen lock", "firewallEnabled": "Firewall enabled", "timeSync": "Time sync", "osVersion": "OS version", "autoUpdate": "Auto update", "passwordPolicy": "Password policy", "remoteLogin": "Remote login", "malwareProtection": "Malware protection" } }, "history": { "title": "Report history", "empty": "No posture reports yet", "checkCount_one": "{{count}} check reported", "checkCount_other": "{{count}} checks reported", "columns": { "time": "Time", "checks": "Checks", "correlationId": "Correlation ID" }, "actions": { "copyCorrelationId": "Copy correlation ID", "correlationIdCopied": "Correlation ID copied" } } }, "employeeDevices": { "title": "Your devices" }, "deviceEnrollment": { "pageTitle": "Enroll device", "title": "Device enrollment", "setup": "Setup", "stepProgress": "Step {{current}} of {{total}}", "steps": { "privacy": { "title": "Privacy", "description": "Review collected data" }, "organization": { "title": "Organization", "description": "Choose destination workspace" }, "enroll": { "title": "Open and wait", "description": "Finish setup in the desktop agent" } }, "unavailable": { "title": "Enrollment unavailable", "description": "You do not have permission to enroll devices in any organization." }, "intro": { "title": "Before you start", "description": "Probo collects the following device metadata for inventory and posture reporting:" }, "privacy": { "identity": "Device identity: hardware UUID, hostname, and serial number (when available).", "systemDetails": "System details: platform, OS version, and Probo agent version.", "activitySignals": "Activity signals: enrollment time, heartbeats, and posture check results." }, "organization": { "title": "Choose organization", "description": "Pick which organization will own and manage this device." }, "openAgent": { "title": "Open the Probo agent", "description": "Open the desktop agent to finish setup, then keep this page open until enrollment is confirmed." }, "actions": { "backToOrganizations": "Back to organizations", "enrollNew": "Enroll new device", "openAgent": "Open Probo agent", "preparing": "Preparing…" }, "status": { "enrolledWithHostname": "{{hostname}} is enrolled.", "enrolled": "This device is enrolled.", "closeWindow": "You can close this window.", "waitingForCheckIn": "Waiting for the agent's first check-in…", "timedOut": "We haven't heard from the agent yet. Make sure the desktop agent is installed and running, then try again." }, "manual": { "cannotEnroll": "Can't enroll new device?", "tryCreating": "Try creating it manually", "title": "Manual enrollment", "creating": "Creating device…" }, "token": { "title": "Enrollment token generated", "description": "Share this enrollment token only with the device owner through a secure channel. It can be used once and expires after seven days.", "manualInstall": "Manual install (CLI / MDM)", "installUnix": "Install on macOS or Linux (run from a shell with sudo access)", "installWindows": "Install on Windows (run from an elevated PowerShell session)", "securityNotice": "The token is passed as a CLI flag (not via curl-piped-to-shell or sudo env vars). Once installed, the agent self-updates from GitHub Releases with cosign signature verification." }, "messages": { "created": "Device created. Copy the enrollment token now — it will not be shown again." }, "errors": { "copyToClipboard": "Failed to copy to clipboard" } }, "thirdPartyRiskAssessmentRow": { diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index 84a875106..c89721032 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -5960,21 +5960,17 @@ "revoke": "Révoquer l’appareil « {{hostname}} » ? L’agent sur l’appareil arrêtera de transmettre des données et devra être réenrôlé." }, "postures": { - "title": "Posture", - "navigation": "Postures", - "summaryHelp": "Comment le résumé de posture est calculé", - "summaryDescription": "Affiché sous la forme réussite / échec / total.", + "currentTitle": "Postures actuelles", "empty": "Aucun contrôle de posture enregistré", - "columns": { - "check": "Contrôle", - "status": "Statut", - "observedAt": "Observé le" - }, - "status": { - "pass": "Réussite", - "fail": "Échec", - "unknown": "Inconnu", - "notApplicable": "Non applicable" + "values": { + "on": "Activé", + "off": "Désactivé", + "immediate": "Immédiat", + "seconds": "{{seconds}} s", + "minPasswordLength": "Longueur min. {{length}}", + "configured": "Configurée", + "none": "Aucune", + "unknown": "Inconnu" }, "checks": { "diskEncryption": "Chiffrement du disque", @@ -5987,6 +5983,21 @@ "remoteLogin": "Connexion à distance", "malwareProtection": "Protection contre les logiciels malveillants" } + }, + "history": { + "title": "Historique des rapports", + "empty": "Aucun rapport de posture pour le moment", + "checkCount_one": "{{count}} contrôle rapporté", + "checkCount_other": "{{count}} contrôles rapportés", + "columns": { + "time": "Heure", + "checks": "Contrôles", + "correlationId": "ID de corrélation" + }, + "actions": { + "copyCorrelationId": "Copier l’ID de corrélation", + "correlationIdCopied": "ID de corrélation copié" + } } }, "employeeDevices": { diff --git a/apps/console/src/pages/organizations/devices/DeviceLayout.tsx b/apps/console/src/pages/organizations/devices/DeviceLayout.tsx index 1b159cf09..f45f4d6d3 100644 --- a/apps/console/src/pages/organizations/devices/DeviceLayout.tsx +++ b/apps/console/src/pages/organizations/devices/DeviceLayout.tsx @@ -19,13 +19,7 @@ // SOFTWARE. import { usePageTitle } from "@probo/hooks"; -import { - Breadcrumb, - Button, - PageHeader, - TabLink, - Tabs, -} from "@probo/ui"; +import { Breadcrumb, Button, PageHeader } from "@probo/ui"; import { useTranslation } from "react-i18next"; import { type PreloadedQuery, @@ -37,6 +31,7 @@ import { graphql } from "relay-runtime"; import type { DeviceLayoutQuery } from "#/__generated__/core/DeviceLayoutQuery.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { DeviceCurrentPostures } from "./_components/DeviceCurrentPostures"; import { DeviceDetailsCard } from "./_components/DeviceDetailsCard"; import { displayValue } from "./_lib/deviceDisplay"; import { useRevokeDevice } from "./_lib/useRevokeDevice"; @@ -49,8 +44,8 @@ export const deviceLayoutQuery = graphql` id state hostname - platform ...DeviceDetailsCard_deviceFragment + ...DeviceCurrentPostures_deviceFragment } } organization: node(id: $organizationId) @required(action: THROW) { @@ -102,10 +97,7 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) { { label: hostnameLabel }, ]} /> - + {!isRevoked && canRevokeDevice && ( - - - -

- {t("devices.postures.summaryDescription")} -

-
-
- - - ); +import { postureValueLabel, postureValueVariant } from "../_lib/deviceDisplay"; + +const postureFragment = graphql` + fragment PostureValueBadge_postureFragment on DevicePosture { + checkKey + value { + kind + text + number + } + } +`; + +interface PostureValueBadgeProps { + postureFragmentRef: PostureValueBadge_postureFragment$key; +} + +export function PostureValueBadge({ + postureFragmentRef, +}: PostureValueBadgeProps) { + const { t } = useTranslation(); + const posture = useFragment(postureFragment, postureFragmentRef); + + const label = postureValueLabel(t, posture.value); + const variant = postureValueVariant(posture.value.kind, posture.checkKey); + + if (!variant) { + return label; + } + + return {label}; } diff --git a/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts b/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts index 085a10996..26ea09878 100644 --- a/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts +++ b/apps/console/src/pages/organizations/devices/_lib/deviceDisplay.ts @@ -38,17 +38,71 @@ export function stateVariant( } } -export function statusVariant( - status: string, -): "success" | "danger" | "warning" | "info" { - switch (status) { - case "PASS": - return "success"; - case "FAIL": - return "danger"; - case "NOT_APPLICABLE": - return "info"; +type Translator = (key: string, options?: Record) => string; + +/** What a posture check observed, as returned by the DevicePostureValue type. */ +export interface PostureValue { + readonly kind: string; + readonly text?: string | null; + readonly number?: number | null; +} + +export function postureValueLabel(t: Translator, value: PostureValue): string { + switch (value.kind) { + case "ON": + return t("devices.postures.values.on"); + case "OFF": + return t("devices.postures.values.off"); + case "IMMEDIATE": + return t("devices.postures.values.immediate"); + case "SECONDS": + return t("devices.postures.values.seconds", { + seconds: value.number ?? 0, + }); + case "MIN_PASSWORD_LENGTH": + return t("devices.postures.values.minPasswordLength", { + length: value.number ?? 0, + }); + case "CONFIGURED": + return t("devices.postures.values.configured"); + case "NONE": + return t("devices.postures.values.none"); + case "TEXT": + return value.text || t("devices.postures.values.unknown"); default: - return "warning"; + return t("devices.postures.values.unknown"); + } +} + +/** + * Badge variant for a posture value. Kinds carrying a measurement render as + * plain text instead: whether a 15 second delay or an 8 character minimum is + * acceptable is a ruleset decision, not something the observation can say. + */ +export function postureValueVariant( + kind: string, + checkKey?: string, +): "success" | "danger" | "info" | undefined { + if (checkKey === "REMOTE_LOGIN") { + // Remote login reports reachability, so On is the exposed state. + switch (kind) { + case "ON": + return "danger"; + case "OFF": + return "info"; + default: + return undefined; + } + } + + switch (kind) { + case "ON": + case "IMMEDIATE": + return "success"; + case "OFF": + case "NONE": + return "danger"; + default: + return undefined; } } diff --git a/apps/console/src/pages/organizations/devices/postures/_lib/getPostureCheckLabel.ts b/apps/console/src/pages/organizations/devices/_lib/getPostureCheckLabel.ts similarity index 100% rename from apps/console/src/pages/organizations/devices/postures/_lib/getPostureCheckLabel.ts rename to apps/console/src/pages/organizations/devices/_lib/getPostureCheckLabel.ts diff --git a/apps/console/src/pages/organizations/devices/postures/DevicePosturesPage.tsx b/apps/console/src/pages/organizations/devices/history/DeviceHistoryPage.tsx similarity index 67% rename from apps/console/src/pages/organizations/devices/postures/DevicePosturesPage.tsx rename to apps/console/src/pages/organizations/devices/history/DeviceHistoryPage.tsx index 003802937..af19c6f79 100644 --- a/apps/console/src/pages/organizations/devices/postures/DevicePosturesPage.tsx +++ b/apps/console/src/pages/organizations/devices/history/DeviceHistoryPage.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2025-2026 Probo Inc . +// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -21,33 +21,33 @@ import { type PreloadedQuery, usePreloadedQuery } from "react-relay"; import { graphql } from "relay-runtime"; -import type { DevicePosturesPageQuery } from "#/__generated__/core/DevicePosturesPageQuery.graphql"; +import type { DeviceHistoryPageQuery } from "#/__generated__/core/DeviceHistoryPageQuery.graphql"; -import { DevicePostureList } from "./_components/DevicePostureList"; +import { DevicePostureReportList } from "./_components/DevicePostureReportList"; -export const devicePosturesPageQuery = graphql` - query DevicePosturesPageQuery($deviceId: ID!) { +export const deviceHistoryPageQuery = graphql` + query DeviceHistoryPageQuery($deviceId: ID!) { device: node(id: $deviceId) @required(action: THROW) { __typename ... on Device { - ...DevicePostureList_deviceFragment + ...DevicePostureReportListFragment } } } `; -interface DevicePosturesPageProps { - queryRef: PreloadedQuery; +interface DeviceHistoryPageProps { + queryRef: PreloadedQuery; } -export function DevicePosturesPage({ queryRef }: DevicePosturesPageProps) { - const { device } = usePreloadedQuery( - devicePosturesPageQuery, +export function DeviceHistoryPage({ queryRef }: DeviceHistoryPageProps) { + const { device } = usePreloadedQuery( + deviceHistoryPageQuery, queryRef, ); if (device.__typename !== "Device") { throw new Error("invalid type for device node"); } - return ; + return ; } diff --git a/apps/console/src/pages/organizations/devices/postures/DevicePosturesPageLoader.tsx b/apps/console/src/pages/organizations/devices/history/DeviceHistoryPageLoader.tsx similarity index 77% rename from apps/console/src/pages/organizations/devices/postures/DevicePosturesPageLoader.tsx rename to apps/console/src/pages/organizations/devices/history/DeviceHistoryPageLoader.tsx index 1f67a3614..1096b9eb1 100644 --- a/apps/console/src/pages/organizations/devices/postures/DevicePosturesPageLoader.tsx +++ b/apps/console/src/pages/organizations/devices/history/DeviceHistoryPageLoader.tsx @@ -1,4 +1,4 @@ -// Copyright (c) 2025-2026 Probo Inc . +// Copyright (c) 2026 Probo Inc . // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -22,20 +22,20 @@ import { Suspense, useEffect } from "react"; import { useQueryLoader } from "react-relay"; import { useParams } from "react-router"; -import type { DevicePosturesPageQuery } from "#/__generated__/core/DevicePosturesPageQuery.graphql"; +import type { DeviceHistoryPageQuery } from "#/__generated__/core/DeviceHistoryPageQuery.graphql"; import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; import { CoreRelayProvider } from "#/providers/CoreRelayProvider"; -import { DevicePosturesPage, devicePosturesPageQuery } from "./DevicePosturesPage"; +import { DeviceHistoryPage, deviceHistoryPageQuery } from "./DeviceHistoryPage"; -function DevicePosturesPageQueryLoader() { +function DeviceHistoryPageQueryLoader() { const { deviceId } = useParams(); if (!deviceId) { throw new Error(":deviceId missing in route params"); } - const [queryRef, loadQuery] = useQueryLoader( - devicePosturesPageQuery, + const [queryRef, loadQuery] = useQueryLoader( + deviceHistoryPageQuery, ); useEffect(() => { @@ -50,15 +50,15 @@ function DevicePosturesPageQueryLoader() { return ( }> - + ); } -export default function DevicePosturesPageLoader() { +export default function DeviceHistoryPageLoader() { return ( - + ); } diff --git a/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx b/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx new file mode 100644 index 000000000..0ca9c95e8 --- /dev/null +++ b/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportList.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { Tbody, Td, Th, Thead, Tr } from "@probo/ui"; +import { useTranslation } from "react-i18next"; +import { graphql, usePaginationFragment } from "react-relay"; + +import type { DevicePostureReportListFragment$key } from "#/__generated__/core/DevicePostureReportListFragment.graphql"; +import type { DevicePostureReportListPaginationQuery } from "#/__generated__/core/DevicePostureReportListPaginationQuery.graphql"; +import { SortableTable } from "#/components/SortableTable"; + +import { DevicePostureReportListItem } from "./DevicePostureReportListItem"; + +const deviceFragment = graphql` + fragment DevicePostureReportListFragment on Device + @refetchable(queryName: "DevicePostureReportListPaginationQuery") + @argumentDefinitions( + first: { type: "Int", defaultValue: 20 } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + ) { + postureReports( + first: $first + after: $after + last: $last + before: $before + ) @connection(key: "DevicePostureReportListFragment_postureReports") { + edges { + node { + createdAt + id + ...DevicePostureReportListItemFragment + } + } + } + } +`; + +interface DevicePostureReportListProps { + fKey: DevicePostureReportListFragment$key; +} + +export function DevicePostureReportList({ + fKey, +}: DevicePostureReportListProps) { + const { t } = useTranslation(); + + const reportsPagination = usePaginationFragment< + DevicePostureReportListPaginationQuery, + DevicePostureReportListFragment$key + >(deviceFragment, fKey); + + const edges = reportsPagination.data.postureReports.edges; + + return ( + { + reportsPagination.refetch({}, { fetchPolicy: "network-only" }); + }} + pageSize={20} + > + + + {t("devices.history.columns.time")} + + {t("devices.history.columns.correlationId")} + + {t("devices.history.columns.checks")} + + + + {edges.length === 0 + ? ( + + + {t("devices.history.empty")} + + + ) + : ( + edges.map(({ node: report }) => ( + + )) + )} + + + ); +} diff --git a/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportListItem.tsx b/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportListItem.tsx new file mode 100644 index 000000000..4094d64bc --- /dev/null +++ b/apps/console/src/pages/organizations/devices/history/_components/DevicePostureReportListItem.tsx @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { useCopy } from "@probo/hooks"; +import { dateTimeFormat } from "@probo/i18n"; +import { + IconCheckmark1, + IconChevronDown, + IconChevronRight, + IconSquareBehindSquare2, + Td, + Tr, +} from "@probo/ui"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +import type { DevicePostureReportListItemFragment$key } from "#/__generated__/core/DevicePostureReportListItemFragment.graphql"; + +import { PostureValueBadge } from "../../_components/PostureValueBadge"; +import { getPostureCheckLabel } from "../../_lib/getPostureCheckLabel"; + +const reportFragment = graphql` + fragment DevicePostureReportListItemFragment on DevicePostureReport { + id + createdAt + postures { + id + checkKey + ...PostureValueBadge_postureFragment + } + } +`; + +interface DevicePostureReportListItemProps { + fKey: DevicePostureReportListItemFragment$key; +} + +export function DevicePostureReportListItem({ + fKey, +}: DevicePostureReportListItemProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [isCopied, copy] = useCopy(); + const { i18n, t } = useTranslation(); + + const report = useFragment(reportFragment, fKey); + + return ( + <> + setIsExpanded(!isExpanded)} + > + +
+ {isExpanded + ? ( + + ) + : ( + + )} + {dateTimeFormat(i18n.language, report.createdAt)} +
+ + +
+ + {report.id} + + +
+ + + {t("devices.history.checkCount", { + count: report.postures.length, + })} + + + {isExpanded && ( + + +
+ {report.postures.map(posture => ( +
+ + {getPostureCheckLabel(t, posture.checkKey)} + + +
+ ))} +
+ + + )} + + ); +} diff --git a/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx b/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx deleted file mode 100644 index 2aeac537b..000000000 --- a/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureList.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Table, Tbody, Td, Th, Thead, Tr } from "@probo/ui"; -import { useTranslation } from "react-i18next"; -import { useFragment } from "react-relay"; -import { graphql } from "relay-runtime"; - -import type { DevicePostureList_deviceFragment$key } from "#/__generated__/core/DevicePostureList_deviceFragment.graphql"; - -import { DevicePostureListItem } from "./DevicePostureListItem"; - -const deviceFragment = graphql` - fragment DevicePostureList_deviceFragment on Device { - latestPostures { - id - ...DevicePostureListItem_postureFragment - } - } -`; - -interface DevicePostureListProps { - deviceFragmentRef: DevicePostureList_deviceFragment$key; -} - -export function DevicePostureList({ deviceFragmentRef }: DevicePostureListProps) { - const { t } = useTranslation(); - const device = useFragment(deviceFragment, deviceFragmentRef); - - return ( - - - - - - - - - - {device.latestPostures.length === 0 && ( - - - - )} - {device.latestPostures.map(posture => ( - - ))} - -
{t("devices.postures.columns.check")}{t("devices.postures.columns.status")}{t("devices.postures.columns.observedAt")}
- {t("devices.postures.empty")} -
- ); -} diff --git a/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx b/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx deleted file mode 100644 index 90ccb6af5..000000000 --- a/apps/console/src/pages/organizations/devices/postures/_components/DevicePostureListItem.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { dateTimeFormat } from "@probo/i18n/date"; -import { Badge, Td, Tr } from "@probo/ui"; -import { useTranslation } from "react-i18next"; -import { useFragment } from "react-relay"; -import { graphql } from "relay-runtime"; - -import type { - DevicePostureListItem_postureFragment$key, -} from "#/__generated__/core/DevicePostureListItem_postureFragment.graphql"; - -import { statusVariant } from "../../_lib/deviceDisplay"; -import { getPostureCheckLabel } from "../_lib/getPostureCheckLabel"; -import { getPostureStatusLabel } from "../_lib/getPostureStatusLabel"; - -const postureFragment = graphql` - fragment DevicePostureListItem_postureFragment on DevicePosture { - checkKey - status - observedAt - } -`; - -interface DevicePostureListItemProps { - postureKey: DevicePostureListItem_postureFragment$key; -} - -export function DevicePostureListItem({ postureKey }: DevicePostureListItemProps) { - const { i18n, t } = useTranslation(); - const posture = useFragment(postureFragment, postureKey); - - return ( - - {getPostureCheckLabel(t, posture.checkKey)} - - - {getPostureStatusLabel(t, posture.status)} - - - - {dateTimeFormat(i18n.language, posture.observedAt)} - - - ); -} diff --git a/apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts b/apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts deleted file mode 100644 index 8f71fff30..000000000 --- a/apps/console/src/pages/organizations/devices/postures/_lib/getPostureStatusLabel.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2025-2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -type Translator = (key: string) => string; - -const statusLabels: Record = { - PASS: "devices.postures.status.pass", - FAIL: "devices.postures.status.fail", - UNKNOWN: "devices.postures.status.unknown", - NOT_APPLICABLE: "devices.postures.status.notApplicable", -}; - -export function getPostureStatusLabel(t: Translator, status: string) { - const label = statusLabels[status]; - return label ? t(label) : status; -} diff --git a/apps/console/src/pages/organizations/devices/routes.ts b/apps/console/src/pages/organizations/devices/routes.ts index 9f4235946..b650494c3 100644 --- a/apps/console/src/pages/organizations/devices/routes.ts +++ b/apps/console/src/pages/organizations/devices/routes.ts @@ -34,17 +34,17 @@ const deviceTabs = () => [ }: LoaderFunctionArgs) => { // eslint-disable-next-line throw redirect( - `/organizations/${organizationId}/devices/${deviceId}/postures`, + `/organizations/${organizationId}/devices/${deviceId}/history`, ); }, Component: Fragment, }, { - path: "postures", + path: "history", Fallback: LinkCardSkeleton, Component: lazy( () => - import("#/pages/organizations/devices/postures/DevicePosturesPageLoader"), + import("#/pages/organizations/devices/history/DeviceHistoryPageLoader"), ), }, ]; diff --git a/contrib/seed.sh b/contrib/seed.sh index dc9217e5f..28cc84b9f 100755 --- a/contrib/seed.sh +++ b/contrib/seed.sh @@ -808,18 +808,241 @@ agent_heartbeat() { fi } -# agent_postures ... +# posture_evidence +# Emits platform-shaped evidence JSON that ParseDevicePostureValue can turn +# into a non-UNKNOWN value for PASS/FAIL, or UNKNOWN/None for the rest. +posture_evidence() { + local platform="$1" + local check_key="$2" + local status="$3" + local os_version="$4" + + case "$platform:$check_key" in + DARWIN:DISK_ENCRYPTION) + case "$status" in + PASS) jq -nc '{raw:"FileVault is On."}' ;; + FAIL) jq -nc '{raw:"FileVault is Off."}' ;; + *) jq -nc '{note:"fdesetup unavailable"}' ;; + esac + ;; + LINUX:DISK_ENCRYPTION) + case "$status" in + PASS) jq -nc '{crypttab_present:true,crypttab_lines:["nvme0n1p3_crypt UUID=6c2f none luks,discard"]}' ;; + FAIL) jq -nc '{crypttab_present:false,lsblk:"nvme0n1 disk\nnvme0n1p2 part ext4 /"}' ;; + *) jq -nc '{crypttab_present:false,lsblk_error:"lsblk not found"}' ;; + esac + ;; + WINDOWS:DISK_ENCRYPTION) + case "$status" in + PASS) jq -nc '{raw:"Conversion Status: Fully Encrypted\n Percentage Encrypted: 100%"}' ;; + FAIL) jq -nc '{raw:"Conversion Status: Fully Decrypted\n Percentage Encrypted: 0%"}' ;; + *) jq -nc '{note:"manage-bde not found"}' ;; + esac + ;; + DARWIN:SCREEN_LOCK) + case "$status" in + PASS) jq -nc '{backend:"sysadminctl",mode:"seconds",seconds:900,raw:"screenLock delay is 900 seconds"}' ;; + FAIL) jq -nc '{backend:"sysadminctl",mode:"off",raw:"screenLock is off"}' ;; + *) jq -nc '{backend:"sysadminctl",error:"sysadminctl failed"}' ;; + esac + ;; + LINUX:SCREEN_LOCK) + case "$status" in + PASS) jq -nc '{backend:"gnome",schema:"org.gnome.desktop.screensaver",lock_enabled:"true"}' ;; + FAIL) jq -nc '{backend:"gnome",schema:"org.gnome.desktop.screensaver",lock_enabled:"false"}' ;; + *) jq -nc '{backend:"gnome",error:"gsettings failed"}' ;; + esac + ;; + WINDOWS:SCREEN_LOCK) + case "$status" in + PASS) jq -nc '{backend:"hkey_users",users:{"S-1-5-21-1004336348-1177238915-682003330-1001":"1"}}' ;; + FAIL) jq -nc '{backend:"hkey_users",users:{"S-1-5-21-1004336348-1177238915-682003330-1001":"0"}}' ;; + *) jq -nc '{backend:"hkey_users",users:{},note:"no interactive user hives loaded"}' ;; + esac + ;; + DARWIN:FIREWALL_ENABLED) + case "$status" in + PASS) jq -nc '{backend:"defaults",global_state:"1"}' ;; + FAIL) jq -nc '{backend:"defaults",global_state:"0"}' ;; + *) jq -nc '{note:"no known firewall tool found"}' ;; + esac + ;; + LINUX:FIREWALL_ENABLED) + case "$status" in + PASS) jq -nc '{backend:"ufw",raw:"Status: active"}' ;; + FAIL) jq -nc '{backend:"ufw",raw:"Status: inactive"}' ;; + *) jq -nc '{note:"no known firewall tool found"}' ;; + esac + ;; + WINDOWS:FIREWALL_ENABLED) + case "$status" in + PASS) jq -nc '{backend:"Get-NetFirewallProfile",raw:"Domain=True;Private=True;Public=True",profiles:{Domain:"True",Private:"True",Public:"True"}}' ;; + FAIL) jq -nc '{backend:"Get-NetFirewallProfile",raw:"Domain=True;Private=True;Public=False",profiles:{Domain:"True",Private:"True",Public:"False"}}' ;; + *) jq -nc '{backend:"netsh",state_lines:[]}' ;; + esac + ;; + DARWIN:TIME_SYNC) + case "$status" in + PASS) jq -nc '{raw:"Network Time: On"}' ;; + FAIL) jq -nc '{raw:"Network Time: Off"}' ;; + *) jq -nc '{note:"systemsetup unavailable"}' ;; + esac + ;; + LINUX:TIME_SYNC) + case "$status" in + PASS) jq -nc '{raw:"Timezone=Europe/Paris\nLocalRTC=no\nCanNTP=yes\nNTP=yes\nNTPSynchronized=yes"}' ;; + FAIL) jq -nc '{raw:"Timezone=Europe/Paris\nLocalRTC=no\nCanNTP=yes\nNTP=yes\nNTPSynchronized=no"}' ;; + *) jq -nc '{note:"timedatectl not installed"}' ;; + esac + ;; + WINDOWS:TIME_SYNC) + case "$status" in + PASS) jq -nc '{raw:"Leap Indicator: 0(no warning)\nStratum: 4 (secondary reference)\nSource: time.windows.com,0x8\nPoll Interval: 10"}' ;; + FAIL) jq -nc '{raw:"Leap Indicator: 3(not synchronized)\nStratum: 0 (unspecified)\nSource: Local CMOS Clock\nPoll Interval: 10"}' ;; + *) jq -nc '{note:"w32tm unavailable"}' ;; + esac + ;; + DARWIN:OS_VERSION) + case "$status" in + UNKNOWN | NOT_APPLICABLE) jq -nc '{error:"sw_vers failed"}' ;; + *) jq -nc --arg v "$os_version" '{product_version:$v,build_version:"24E248"}' ;; + esac + ;; + LINUX:OS_VERSION) + case "$status" in + UNKNOWN | NOT_APPLICABLE) jq -nc '{error:"os-release unreadable"}' ;; + *) jq -nc --arg v "$os_version" '{pretty_name:$v,version_id:$v,id:"linux"}' ;; + esac + ;; + WINDOWS:OS_VERSION) + case "$status" in + UNKNOWN | NOT_APPLICABLE) jq -nc '{error:"wmic failed"}' ;; + *) jq -nc --arg v "$os_version" '{caption:$v}' ;; + esac + ;; + DARWIN:AUTO_UPDATE) + case "$status" in + PASS) jq -nc '{backend:"defaults",AutomaticCheckEnabled:{source:"system",value:"1",enabled:true},AutomaticDownload:{source:"default",enabled:true}}' ;; + FAIL) jq -nc '{backend:"defaults",disabled_keys:["AutomaticDownload"]}' ;; + *) jq -nc '{backend:"defaults",indeterminate_keys:["ConfigDataInstall"]}' ;; + esac + ;; + LINUX:AUTO_UPDATE) + case "$status" in + PASS) jq -nc '{backend:"unattended-upgrades",raw:"APT::Periodic::Update-Package-Lists \"1\";\nAPT::Periodic::Unattended-Upgrade \"1\";\n"}' ;; + FAIL) jq -nc '{backend:"unattended-upgrades",raw:"APT::Periodic::Update-Package-Lists \"0\";\nAPT::Periodic::Unattended-Upgrade \"0\";\n"}' ;; + *) jq -nc '{note:"unattended-upgrades not installed"}' ;; + esac + ;; + WINDOWS:AUTO_UPDATE) + case "$status" in + PASS) jq -nc '{no_auto_update:"0",au_options:"4"}' ;; + FAIL) jq -nc '{no_auto_update:"",au_options:"2"}' ;; + *) jq -nc '{no_auto_update:"",au_options:"",wuauserv:""}' ;; + esac + ;; + DARWIN:PASSWORD_POLICY) + case "$status" in + PASS) jq -nc '{raw_truncated:"policyCategoryPasswordContent"}' ;; + FAIL) jq -nc '{raw_truncated:"There are no account policies for all users."}' ;; + *) jq -nc '{error:"pwpolicy failed"}' ;; + esac + ;; + LINUX:PASSWORD_POLICY) + case "$status" in + PASS) jq -nc '{pass_min_len:"12",pass_max_days:"90",pass_min_len_value:12}' ;; + FAIL) jq -nc '{pass_min_len:"",pass_max_days:"99999",parse_error:"PASS_MIN_LEN not set"}' ;; + *) jq -nc '{error:"login.defs unreadable"}' ;; + esac + ;; + WINDOWS:PASSWORD_POLICY) + case "$status" in + PASS) jq -nc '{raw:"Minimum password length: 8\n"}' ;; + FAIL) jq -nc '{raw:"Minimum password length: 0\n"}' ;; + *) jq -nc '{error:"net accounts failed"}' ;; + esac + ;; + # REMOTE_LOGIN is inverted: PASS means remote access is Off / denied. + DARWIN:REMOTE_LOGIN) + case "$status" in + PASS) jq -nc '{raw:"Remote Login: Off"}' ;; + FAIL) jq -nc '{raw:"Remote Login: On"}' ;; + *) jq -nc '{error:"systemsetup failed"}' ;; + esac + ;; + LINUX:REMOTE_LOGIN) + case "$status" in + PASS) jq -nc '{is_active:"inactive"}' ;; + FAIL) jq -nc '{is_active:"active"}' ;; + *) jq -nc '{is_active:""}' ;; + esac + ;; + WINDOWS:REMOTE_LOGIN) + case "$status" in + PASS) jq -nc '{fdeny_ts_connections:"1"}' ;; + FAIL) jq -nc '{fdeny_ts_connections:"0"}' ;; + *) jq -nc '{error:"registry read failed"}' ;; + esac + ;; + DARWIN:MALWARE_PROTECTION) + case "$status" in + PASS) jq -nc '{engine:"XProtect",version:"5260"}' ;; + FAIL) jq -nc '{engine:"XProtect",note:"XProtect.meta.plist not found in expected locations"}' ;; + *) jq -nc '{note:"XProtect check skipped"}' ;; + esac + ;; + LINUX:MALWARE_PROTECTION) + case "$status" in + PASS) jq -nc '{active:["ClamAV"],installed:[]}' ;; + FAIL) jq -nc '{active:[],installed:["ClamAV"]}' ;; + *) jq -nc '{active:[],installed:[]}' ;; + esac + ;; + WINDOWS:MALWARE_PROTECTION) + case "$status" in + PASS) jq -nc '{antivirus_enabled:true,real_time_protection:true,am_service_enabled:true}' ;; + FAIL) jq -nc '{antivirus_enabled:false,real_time_protection:false,am_service_enabled:false}' ;; + *) jq -nc '{note:"defender status unavailable"}' ;; + esac + ;; + *) + jq -nc '{}' + ;; + esac +} + +# agent_postures ... agent_postures() { local api_key="$1" - shift + local platform="$2" + local os_version="$3" + shift 3 local now now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + local results='[]' + local pair check_key status evidence + for pair in "$@"; do + check_key="${pair%%:*}" + status="${pair#*:}" + evidence=$(posture_evidence "$platform" "$check_key" "$status" "$os_version") + results=$(jq -nc \ + --argjson results "$results" \ + --arg check_key "$check_key" \ + --arg status "$status" \ + --arg observed_at "$now" \ + --argjson evidence "$evidence" \ + '$results + [{ + check_key: $check_key, + status: $status, + observed_at: $observed_at, + evidence: $evidence + }]') + done + local body - body=$(printf '%s\n' "$@" \ - | jq -R --arg o "$now" 'split(":") | {check_key: .[0], status: .[1], observed_at: $o}' \ - | jq -s '{results: .}') + body=$(jq -nc --argjson results "$results" '{results: $results}') local code code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ @@ -829,6 +1052,7 @@ agent_postures() { "$AGENT_API/postures") if [ "$code" != "204" ] && [ "$code" != "200" ]; then echo "ERROR (agent_postures): HTTP $code" >&2 + echo " request: $body" >&2 exit 1 fi } @@ -868,7 +1092,7 @@ seed_device() { hardware_uuid="hw-$(echo "$hostname" | tr '[:upper:]' '[:lower:]')" agent_heartbeat "$api_key" "$hardware_uuid" "$hostname" "$platform" "$os_version" "1.0.0" "$serial" - agent_postures "$api_key" "$@" + agent_postures "$api_key" "$platform" "$os_version" "$@" echo "$device_id" } diff --git a/e2e/console/device_enrollment_test.go b/e2e/console/device_enrollment_test.go index 735442937..cdec30e37 100644 --- a/e2e/console/device_enrollment_test.go +++ b/e2e/console/device_enrollment_test.go @@ -28,6 +28,7 @@ import ( "net/http" "net/url" "testing" + "time" "github.com/stretchr/testify/require" "go.probo.inc/probo/e2e/internal/testutil" @@ -121,8 +122,69 @@ const ( } } }` + + devicePostureReportsQuery = ` + query DevicePostureReports($id: ID!) { + node(id: $id) { + ... on Device { + latestPostures { + id + checkKey + status + value { kind text number } + } + postureReports(first: 10) { + totalCount + edges { + cursor + node { + id + createdAt + postures { + id + checkKey + value { kind text number } + } + } + } + } + } + } + }` ) +type devicePostureValue struct { + Kind string `json:"kind"` + Text string `json:"text"` + Number *int `json:"number"` +} + +type devicePostureReportsResult struct { + Node struct { + LatestPostures []struct { + ID string `json:"id"` + CheckKey string `json:"checkKey"` + Status string `json:"status"` + Value devicePostureValue `json:"value"` + } `json:"latestPostures"` + PostureReports struct { + TotalCount int `json:"totalCount"` + Edges []struct { + Cursor string `json:"cursor"` + Node struct { + ID string `json:"id"` + CreatedAt string `json:"createdAt"` + Postures []struct { + ID string `json:"id"` + CheckKey string `json:"checkKey"` + Value devicePostureValue `json:"value"` + } `json:"postures"` + } `json:"node"` + } `json:"edges"` + } `json:"postureReports"` + } `json:"node"` +} + type enrollDeviceResult struct { EnrollDevice struct { EnrollmentToken string `json:"enrollmentToken"` @@ -292,6 +354,106 @@ func enrollAndActivateDevice( return enrolled } +func enrollActivateAndAuthenticateDevice( + t *testing.T, + client *testutil.Client, + organizationID string, +) (enrollDeviceResult, string) { + t.Helper() + + enrolled := enrollDevice(t, client, organizationID) + + status, payload := exchangeEnrollmentToken( + t, + enrolled.EnrollDevice.EnrollmentToken, + ) + require.Equal(t, http.StatusOK, status) + require.NotEmpty(t, payload.APIKey) + + require.Equal( + t, + http.StatusOK, + sendHeartbeat(t, payload.APIKey, enrolled.EnrollDevice.Device.ID+"-hw"), + ) + + return enrolled, payload.APIKey +} + +func reportPostures(t *testing.T, apiKey string, results []map[string]any) int { + t.Helper() + + body, err := json.Marshal(map[string]any{"results": results}) + require.NoError(t, err) + + req, err := http.NewRequest( + http.MethodPost, + testutil.GetBaseURL()+"/api/agent/v1/postures", + bytes.NewReader(body), + ) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + + return resp.StatusCode +} + +func newPostureCorrelationID(t *testing.T, deviceID string) string { + t.Helper() + + id, err := gid.ParseGID(deviceID) + require.NoError(t, err) + + return gid.New(id.TenantID(), coredata.DevicePostureReportEntityType).String() +} + +// ufwInactivePosture is the evidence a Linux host with a disabled firewall +// reports. The server must read it as OFF: "inactive" contains "active", so a +// substring test inverts the signal. +func ufwInactivePosture( + observedAt time.Time, + correlationID string, +) map[string]any { + result := map[string]any{ + "check_key": "FIREWALL_ENABLED", + "status": "FAIL", + "observed_at": observedAt.Format(time.RFC3339Nano), + "evidence": map[string]any{ + "backend": "ufw", + "raw": "Status: inactive", + }, + } + if correlationID != "" { + result["correlation_id"] = correlationID + } + + return result +} + +func osVersionPosture( + observedAt time.Time, + version string, + correlationID string, +) map[string]any { + result := map[string]any{ + "check_key": "OS_VERSION", + "status": "PASS", + "observed_at": observedAt.Format(time.RFC3339Nano), + "evidence": map[string]any{ + "pretty_name": version, + }, + } + if correlationID != "" { + result["correlation_id"] = correlationID + } + + return result +} + func createDevice( t *testing.T, client *testutil.Client, @@ -880,3 +1042,241 @@ func TestDeviceEnrollmentPermissionQueryShape(t *testing.T) { }) } } + +func TestDevicePostureReports(t *testing.T) { + t.Parallel() + + t.Run("one agent run becomes one report", func(t *testing.T) { + t.Parallel() + + owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t) + + enrolled, apiKey := enrollActivateAndAuthenticateDevice(t, employee, orgID) + deviceID := enrolled.EnrollDevice.Device.ID + + observedAt := time.Now().UTC() + correlationID := newPostureCorrelationID(t, deviceID) + require.Equal( + t, + http.StatusNoContent, + reportPostures(t, apiKey, []map[string]any{ + ufwInactivePosture(observedAt, correlationID), + osVersionPosture(observedAt, "Ubuntu 24.04.2 LTS", correlationID), + }), + ) + + var result devicePostureReportsResult + owner.MustExecute( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + &result, + ) + + reports := result.Node.PostureReports + require.Equal(t, 1, reports.TotalCount) + require.Len(t, reports.Edges, 1) + + report := reports.Edges[0].Node + require.Equal(t, correlationID, report.ID) + require.NotEmpty(t, report.CreatedAt) + require.NotEmpty(t, reports.Edges[0].Cursor) + require.Len(t, report.Postures, 2) + + values := map[string]devicePostureValue{} + for _, posture := range report.Postures { + values[posture.CheckKey] = posture.Value + } + + require.Equal(t, "OFF", values["FIREWALL_ENABLED"].Kind) + require.Equal(t, "TEXT", values["OS_VERSION"].Kind) + require.Equal(t, "Ubuntu 24.04.2 LTS", values["OS_VERSION"].Text) + + require.Len(t, result.Node.LatestPostures, 2) + + for _, posture := range result.Node.LatestPostures { + require.Equal( + t, + values[posture.CheckKey].Kind, + posture.Value.Kind, + "latest posture and report disagree on %s", + posture.CheckKey, + ) + } + }) + + t.Run("legacy agent without correlation_id still groups one report", func(t *testing.T) { + t.Parallel() + + owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t) + + enrolled, apiKey := enrollActivateAndAuthenticateDevice(t, employee, orgID) + deviceID := enrolled.EnrollDevice.Device.ID + + observedAt := time.Now().UTC() + require.Equal( + t, + http.StatusNoContent, + reportPostures(t, apiKey, []map[string]any{ + ufwInactivePosture(observedAt, ""), + osVersionPosture(observedAt, "Ubuntu 24.04.2 LTS", ""), + }), + ) + + var result devicePostureReportsResult + owner.MustExecute( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + &result, + ) + + reports := result.Node.PostureReports + require.Equal(t, 1, reports.TotalCount) + require.Len(t, reports.Edges, 1) + + report := reports.Edges[0].Node + reportID, err := gid.ParseGID(report.ID) + require.NoError(t, err) + require.Equal(t, coredata.DevicePostureReportEntityType, reportID.EntityType()) + + deviceGID, err := gid.ParseGID(deviceID) + require.NoError(t, err) + require.Equal(t, deviceGID.TenantID(), reportID.TenantID()) + + require.Len(t, report.Postures, 2) + + values := map[string]devicePostureValue{} + for _, posture := range report.Postures { + values[posture.CheckKey] = posture.Value + } + + require.Equal(t, "OFF", values["FIREWALL_ENABLED"].Kind) + require.Equal(t, "TEXT", values["OS_VERSION"].Kind) + require.Equal(t, "Ubuntu 24.04.2 LTS", values["OS_VERSION"].Text) + }) + + t.Run("each agent run adds a report", func(t *testing.T) { + t.Parallel() + + owner, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t) + + enrolled, apiKey := enrollActivateAndAuthenticateDevice(t, employee, orgID) + deviceID := enrolled.EnrollDevice.Device.ID + + firstCorrelationID := newPostureCorrelationID(t, deviceID) + require.Equal( + t, + http.StatusNoContent, + reportPostures(t, apiKey, []map[string]any{ + osVersionPosture(time.Now().UTC(), "Ubuntu 24.04.1 LTS", firstCorrelationID), + }), + ) + + var first devicePostureReportsResult + owner.MustExecute( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + &first, + ) + require.Equal(t, 1, first.Node.PostureReports.TotalCount) + + secondCorrelationID := newPostureCorrelationID(t, deviceID) + require.Equal( + t, + http.StatusNoContent, + reportPostures(t, apiKey, []map[string]any{ + osVersionPosture(time.Now().UTC(), "Ubuntu 24.04.2 LTS", secondCorrelationID), + }), + ) + + var second devicePostureReportsResult + owner.MustExecute( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + &second, + ) + require.Equal(t, 2, second.Node.PostureReports.TotalCount) + require.Len(t, second.Node.PostureReports.Edges, 2) + + newest := second.Node.PostureReports.Edges[0].Node + oldest := second.Node.PostureReports.Edges[1].Node + + require.Equal(t, secondCorrelationID, newest.ID) + require.Equal(t, firstCorrelationID, oldest.ID) + require.NotEqual(t, newest.CreatedAt, oldest.CreatedAt) + require.Equal( + t, + first.Node.PostureReports.Edges[0].Node.CreatedAt, + oldest.CreatedAt, + "the first run's report must survive the second", + ) + require.Equal( + t, + "Ubuntu 24.04.2 LTS", + newest.Postures[0].Value.Text, + ) + }) + + t.Run("viewer can read posture reports", func(t *testing.T) { + t.Parallel() + + _, _, employee, viewer, orgID, _ := setupDeviceEnrollmentClients(t) + + enrolled, apiKey := enrollActivateAndAuthenticateDevice(t, employee, orgID) + deviceID := enrolled.EnrollDevice.Device.ID + + require.Equal( + t, + http.StatusNoContent, + reportPostures(t, apiKey, []map[string]any{ + osVersionPosture( + time.Now().UTC(), + "Ubuntu 24.04.2 LTS", + newPostureCorrelationID(t, deviceID), + ), + }), + ) + + var result devicePostureReportsResult + viewer.MustExecute( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + &result, + ) + require.Equal(t, 1, result.Node.PostureReports.TotalCount) + + err := employee.ExecuteShouldFail( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + ) + require.Error(t, err, "employee must not read device postures") + }) + + t.Run("another organization cannot read posture reports", func(t *testing.T) { + t.Parallel() + + _, _, employee, _, orgID, _ := setupDeviceEnrollmentClients(t) + + enrolled, apiKey := enrollActivateAndAuthenticateDevice(t, employee, orgID) + deviceID := enrolled.EnrollDevice.Device.ID + + require.Equal( + t, + http.StatusNoContent, + reportPostures(t, apiKey, []map[string]any{ + osVersionPosture( + time.Now().UTC(), + "Ubuntu 24.04.2 LTS", + newPostureCorrelationID(t, deviceID), + ), + }), + ) + + outsider := testutil.NewClient(t, testutil.RoleOwner) + + err := outsider.ExecuteShouldFail( + devicePostureReportsQuery, + map[string]any{"id": deviceID}, + ) + require.Error(t, err, "another organization must not read device postures") + }) +} diff --git a/pkg/coredata/device_posture.go b/pkg/coredata/device_posture.go index 0b92c0450..6ff89b7f2 100644 --- a/pkg/coredata/device_posture.go +++ b/pkg/coredata/device_posture.go @@ -39,6 +39,7 @@ type ( TenantID gid.TenantID `db:"tenant_id"` OrganizationID gid.GID `db:"organization_id"` DeviceID gid.GID `db:"device_id"` + CorrelationID gid.GID `db:"correlation_id"` CheckKey string `db:"check_key"` Status DevicePostureStatus `db:"status"` Evidence json.RawMessage `db:"evidence"` @@ -86,6 +87,7 @@ INSERT INTO device_postures ( tenant_id, organization_id, device_id, + correlation_id, check_key, status, evidence, @@ -96,6 +98,7 @@ INSERT INTO device_postures ( @tenant_id, @organization_id, @device_id, + @correlation_id, @check_key, @status, @evidence, @@ -108,11 +111,12 @@ INSERT INTO device_postures ( "tenant_id": scope.GetTenantID(), "organization_id": p.OrganizationID, "device_id": p.DeviceID, + "correlation_id": p.CorrelationID, "check_key": p.CheckKey, "status": p.Status, "evidence": evidence, "observed_at": observedAt, - "created_at": p.CreatedAt, + "created_at": now, } if _, err := conn.Exec(ctx, q, args); err != nil { @@ -138,8 +142,9 @@ func normalizeObservedAt(observed, now time.Time) time.Time { return observed } -// LoadLatestByDeviceID loads a page of the latest posture row for each -// check_key on the given device. +// LoadLatestByDeviceID loads the latest posture per check_key by observed_at. +// A check that errored in the last run keeps its last known result, so the set +// can span multiple reports. func (p *DevicePostures) LoadLatestByDeviceID( ctx context.Context, conn pg.Querier, @@ -154,6 +159,7 @@ WITH latest AS ( tenant_id, organization_id, device_id, + correlation_id, check_key, status, evidence, @@ -189,6 +195,64 @@ SELECT * FROM latest WHERE %s return nil } +func (p *DevicePostures) LoadByDeviceIDAndCorrelationIDs( + ctx context.Context, + conn pg.Querier, + scope Scoper, + deviceID gid.GID, + correlationIDs []gid.GID, +) error { + if len(correlationIDs) == 0 { + *p = nil + + return nil + } + + q := ` +SELECT + id, + tenant_id, + organization_id, + device_id, + correlation_id, + check_key, + status, + evidence, + observed_at, + created_at +FROM + device_postures +WHERE + %s + AND device_id = @device_id + AND correlation_id = ANY(@correlation_ids) +ORDER BY + created_at DESC, + check_key ASC +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "device_id": deviceID, + "correlation_ids": correlationIDs, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device postures by correlation_id: %w", err) + } + + postures, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[DevicePosture]) + if err != nil { + return fmt.Errorf("cannot collect device postures by correlation_id: %w", err) + } + + *p = postures + + return nil +} + // LoadHistoryByDeviceIDAndCheckKey returns the most recent N entries for one // (device, check_key) pair, newest first. func (p *DevicePostures) LoadHistoryByDeviceIDAndCheckKey( @@ -209,6 +273,7 @@ SELECT tenant_id, organization_id, device_id, + correlation_id, check_key, status, evidence, diff --git a/pkg/coredata/device_posture_report.go b/pkg/coredata/device_posture_report.go new file mode 100644 index 000000000..d94ac886c --- /dev/null +++ b/pkg/coredata/device_posture_report.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "context" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +type ( + DevicePostureReport struct { + ID gid.GID `db:"id"` + DeviceID gid.GID `db:"device_id"` + CreatedAt time.Time `db:"created_at"` + Postures DevicePostures `db:"-"` + } + + DevicePostureReports []*DevicePostureReport +) + +func (s DevicePostureReport) CursorKey( + orderBy DevicePostureReportOrderField, +) page.CursorKey { + switch orderBy { + case DevicePostureReportOrderFieldCreatedAt: + return page.NewCursorKey(s.ID, s.CreatedAt) + } + + panic(fmt.Sprintf("unsupported order by: %s", orderBy)) +} + +func (s *DevicePostureReports) LoadByDeviceID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + deviceID gid.GID, + cursor *page.Cursor[DevicePostureReportOrderField], +) error { + q := ` +WITH reports AS ( + SELECT + correlation_id AS id, + device_id, + MIN(created_at) AS created_at + FROM + device_postures + WHERE + %s + AND device_id = @device_id + GROUP BY + device_id, + correlation_id +) +SELECT + id, + device_id, + created_at +FROM + reports +WHERE %s +` + q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment()) + + args := pgx.StrictNamedArgs{"device_id": deviceID} + maps.Copy(args, scope.SQLArguments()) + maps.Copy(args, cursor.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query device posture reports: %w", err) + } + + reports, err := pgx.CollectRows( + rows, + pgx.RowToAddrOfStructByName[DevicePostureReport], + ) + if err != nil { + return fmt.Errorf("cannot collect device posture reports: %w", err) + } + + *s = reports + + return nil +} + +func (s *DevicePostureReports) CountByDeviceID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + deviceID gid.GID, +) (int, error) { + q := ` +SELECT + COUNT(*) +FROM ( + SELECT + correlation_id + FROM + device_postures + WHERE + %s + AND device_id = @device_id + GROUP BY + correlation_id +) AS reports +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{"device_id": deviceID} + maps.Copy(args, scope.SQLArguments()) + + row := conn.QueryRow(ctx, q, args) + + var count int + if err := row.Scan(&count); err != nil { + return 0, fmt.Errorf("cannot count device posture reports: %w", err) + } + + return count, nil +} diff --git a/pkg/coredata/device_posture_report_order_field.go b/pkg/coredata/device_posture_report_order_field.go new file mode 100644 index 000000000..5769ed8e6 --- /dev/null +++ b/pkg/coredata/device_posture_report_order_field.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" + + "go.probo.inc/probo/pkg/page" +) + +type DevicePostureReportOrderField string + +const ( + DevicePostureReportOrderFieldCreatedAt DevicePostureReportOrderField = "CREATED_AT" +) + +var ( + _ page.OrderField = DevicePostureReportOrderField("") + _ fmt.Stringer = DevicePostureReportOrderField("") + _ encoding.TextMarshaler = DevicePostureReportOrderField("") + _ encoding.TextUnmarshaler = (*DevicePostureReportOrderField)(nil) +) + +func DevicePostureReportOrderFields() []DevicePostureReportOrderField { + return []DevicePostureReportOrderField{ + DevicePostureReportOrderFieldCreatedAt, + } +} + +func (v DevicePostureReportOrderField) IsValid() bool { + switch v { + case DevicePostureReportOrderFieldCreatedAt: + return true + } + + return false +} + +func (v DevicePostureReportOrderField) String() string { + return string(v) +} + +func (v DevicePostureReportOrderField) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DevicePostureReportOrderField) UnmarshalText(text []byte) error { + val := DevicePostureReportOrderField(text) + if !val.IsValid() { + return fmt.Errorf("invalid DevicePostureReportOrderField value: %q", string(text)) + } + + *v = val + + return nil +} + +func (f DevicePostureReportOrderField) Column() string { + switch f { + case DevicePostureReportOrderFieldCreatedAt: + return "created_at" + } + + panic(fmt.Sprintf("unsupported order by: %s", f)) +} diff --git a/pkg/coredata/device_posture_report_test.go b/pkg/coredata/device_posture_report_test.go new file mode 100644 index 000000000..9421b0ea3 --- /dev/null +++ b/pkg/coredata/device_posture_report_test.go @@ -0,0 +1,322 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/internal/test" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/page" +) + +func insertDevicePostureWithEvidence( + t *testing.T, + ctx context.Context, + client *pg.Client, + fx devicePostureFixture, + checkKey string, + status coredata.DevicePostureStatus, + evidence map[string]any, + correlationID gid.GID, + createdAt time.Time, +) { + t.Helper() + + raw, err := json.Marshal(evidence) + require.NoError(t, err) + + posture := coredata.DevicePosture{ + ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType), + OrganizationID: fx.organizationID, + DeviceID: fx.deviceID, + CorrelationID: correlationID, + CheckKey: checkKey, + Status: status, + Evidence: raw, + ObservedAt: createdAt, + CreatedAt: createdAt, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return posture.Insert(ctx, tx, fx.scope) + })) +} + +func TestDevicePostureReport_LoadByDeviceID_GroupsByCorrelationID(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedDevicePostureFixture(t, ctx, client) + + now := time.Now().UTC().Truncate(time.Microsecond) + older := now.Add(-time.Hour) + newer := now + olderCorr := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType) + newerCorr := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType) + + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "OS_VERSION", + coredata.DevicePostureStatusPass, + map[string]any{"product_version": "14.0"}, + olderCorr, + older, + ) + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "DISK_ENCRYPTION", + coredata.DevicePostureStatusPass, + map[string]any{"raw": "FileVault is On."}, + olderCorr, + older, + ) + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "OS_VERSION", + coredata.DevicePostureStatusPass, + map[string]any{"product_version": "15.4"}, + newerCorr, + newer, + ) + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{ + Field: coredata.DevicePostureReportOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + cursor := page.NewCursor(10, nil, page.Head, orderBy) + + var reports coredata.DevicePostureReports + require.NoError(t, reports.LoadByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor)) + + p := page.NewPage(reports, cursor) + require.Len(t, p.Data, 2) + assert.Equal(t, newerCorr, p.Data[0].ID) + assert.Equal(t, olderCorr, p.Data[1].ID) + assert.True(t, p.Data[0].CreatedAt.Equal(newer)) + assert.True(t, p.Data[1].CreatedAt.Equal(older)) + + correlationIDs := []gid.GID{p.Data[0].ID, p.Data[1].ID} + + var postures coredata.DevicePostures + require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs( + ctx, conn, fx.scope, fx.deviceID, correlationIDs, + )) + require.Len(t, postures, 3) + + var counter coredata.DevicePostureReports + + count, err := counter.CountByDeviceID(ctx, conn, fx.scope, fx.deviceID) + require.NoError(t, err) + assert.Equal(t, 2, count) + + return nil + })) +} + +func TestDevicePostureReport_LoadByDeviceID_IDIsCorrelationID(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedDevicePostureFixture(t, ctx, client) + + createdAt := time.Now().UTC().Truncate(time.Microsecond) + correlationID := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType) + + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "OS_VERSION", + coredata.DevicePostureStatusPass, + map[string]any{"product_version": "15.4"}, + correlationID, + createdAt, + ) + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "DISK_ENCRYPTION", + coredata.DevicePostureStatusPass, + map[string]any{"raw": "FileVault is On."}, + correlationID, + createdAt, + ) + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + reports := loadDevicePostureReports(t, ctx, conn, fx, 10, nil) + require.Len(t, reports, 1) + + report := reports[0] + assert.Equal(t, correlationID, report.ID) + + var postures coredata.DevicePostures + require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs( + ctx, conn, fx.scope, fx.deviceID, []gid.GID{report.ID}, + )) + require.Len(t, postures, 2) + + for _, posture := range postures { + assert.Equal(t, correlationID, posture.CorrelationID) + } + + return nil + })) +} + +func TestDevicePostureReport_LoadByDeviceID_PaginatesAcrossPages(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedDevicePostureFixture(t, ctx, client) + + now := time.Now().UTC().Truncate(time.Microsecond) + createdAts := []time.Time{ + now.Add(-2 * time.Hour), + now.Add(-time.Hour), + now, + } + + correlationIDs := make([]gid.GID, len(createdAts)) + for i := range createdAts { + correlationIDs[i] = gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType) + } + + for i, createdAt := range createdAts { + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "OS_VERSION", + coredata.DevicePostureStatusPass, + map[string]any{"product_version": fmt.Sprintf("15.%d", i)}, + correlationIDs[i], + createdAt, + ) + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "DISK_ENCRYPTION", + coredata.DevicePostureStatusPass, + map[string]any{"raw": "FileVault is On."}, + correlationIDs[i], + createdAt, + ) + } + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{ + Field: coredata.DevicePostureReportOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + + first := loadDevicePostureReports(t, ctx, conn, fx, 2, nil) + require.Len(t, first, 2) + assert.Equal(t, correlationIDs[2], first[0].ID) + assert.Equal(t, correlationIDs[1], first[1].ID) + assert.True(t, first[0].CreatedAt.Equal(createdAts[2])) + assert.True(t, first[1].CreatedAt.Equal(createdAts[1])) + + after := first[1].CursorKey(orderBy.Field) + + second := loadDevicePostureReports(t, ctx, conn, fx, 2, &after) + require.Len(t, second, 1) + assert.Equal(t, correlationIDs[0], second[0].ID) + assert.True(t, second[0].CreatedAt.Equal(createdAts[0])) + + return nil + })) +} + +func TestDevicePostureReport_LoadByDeviceID_IsTenantScoped(t *testing.T) { + t.Parallel() + + client := test.PGClient(t) + ctx := context.Background() + fx := seedDevicePostureFixture(t, ctx, client) + other := seedDevicePostureFixture(t, ctx, client) + + createdAt := time.Now().UTC().Truncate(time.Microsecond) + correlationID := gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType) + + insertDevicePostureWithEvidence( + t, ctx, client, fx, + "OS_VERSION", + coredata.DevicePostureStatusPass, + map[string]any{"product_version": "15.4"}, + correlationID, + createdAt, + ) + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{ + Field: coredata.DevicePostureReportOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + cursor := page.NewCursor(10, nil, page.Head, orderBy) + + var reports coredata.DevicePostureReports + require.NoError(t, reports.LoadByDeviceID( + ctx, conn, other.scope, fx.deviceID, cursor, + )) + assert.Empty(t, reports) + + count, err := reports.CountByDeviceID(ctx, conn, other.scope, fx.deviceID) + require.NoError(t, err) + assert.Zero(t, count) + + var postures coredata.DevicePostures + require.NoError(t, postures.LoadByDeviceIDAndCorrelationIDs( + ctx, conn, other.scope, fx.deviceID, []gid.GID{correlationID}, + )) + assert.Empty(t, postures) + + return nil + })) +} + +func loadDevicePostureReports( + t *testing.T, + ctx context.Context, + conn pg.Querier, + fx devicePostureFixture, + size int, + from *page.CursorKey, +) coredata.DevicePostureReports { + t.Helper() + + orderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{ + Field: coredata.DevicePostureReportOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + cursor := page.NewCursor(size, from, page.Head, orderBy) + + var reports coredata.DevicePostureReports + require.NoError(t, reports.LoadByDeviceID(ctx, conn, fx.scope, fx.deviceID, cursor)) + + return page.NewPage(reports, cursor).Data +} diff --git a/pkg/coredata/device_posture_test.go b/pkg/coredata/device_posture_test.go index d14b0497f..0ff351c14 100644 --- a/pkg/coredata/device_posture_test.go +++ b/pkg/coredata/device_posture_test.go @@ -117,6 +117,7 @@ func insertDevicePosture( ID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureEntityType), OrganizationID: fx.organizationID, DeviceID: fx.deviceID, + CorrelationID: gid.New(fx.scope.GetTenantID(), coredata.DevicePostureReportEntityType), CheckKey: checkKey, Status: status, ObservedAt: observedAt, diff --git a/pkg/coredata/device_posture_value.go b/pkg/coredata/device_posture_value.go new file mode 100644 index 000000000..b22f82025 --- /dev/null +++ b/pkg/coredata/device_posture_value.go @@ -0,0 +1,409 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "encoding/json" + "strconv" + "strings" + "unicode/utf8" +) + +// devicePostureValueTextMax bounds a TEXT value. Evidence literals we surface +// are short by nature (a version, an engine name, a few agent names); the cap +// only guards against a pathological host. +const devicePostureValueTextMax = 80 + +// DevicePostureValue is the observation a posture check made, in a shape a +// client can localize. Status is deliberately absent: whether the observation +// is acceptable is a ruleset decision, not a property of the measurement. +type DevicePostureValue struct { + Kind DevicePostureValueKind + Text string + Number *int +} + +// ParseDevicePostureValue derives the observed value of a posture check from +// the evidence the agent recorded. +// +// Evidence shapes differ per platform and per tool, so each check dispatches on +// the "backend" key the agent sets (or, where it sets none, on a distinctive +// key). Unrecognised evidence yields UNKNOWN — never a guess, and never raw +// command output, which can carry usernames and file paths. +func ParseDevicePostureValue( + checkKey string, + evidence json.RawMessage, +) DevicePostureValue { + ev := decodeEvidenceMap(evidence) + if len(ev) == 0 { + return unknownValue() + } + + switch DevicePostureCheckKey(checkKey) { + case DevicePostureCheckKeyOSVersion: + return parseOSVersionValue(ev) + case DevicePostureCheckKeyDiskEncryption: + return parseDiskEncryptionValue(ev) + case DevicePostureCheckKeyScreenLock: + return parseScreenLockValue(ev) + case DevicePostureCheckKeyFirewallEnabled: + return parseFirewallValue(ev) + case DevicePostureCheckKeyTimeSync: + return parseTimeSyncValue(ev) + case DevicePostureCheckKeyAutoUpdate: + return parseAutoUpdateValue(ev) + case DevicePostureCheckKeyPasswordPolicy: + return parsePasswordPolicyValue(ev) + case DevicePostureCheckKeyRemoteLogin: + return parseRemoteLoginValue(ev) + case DevicePostureCheckKeyMalwareProtection: + return parseMalwareProtectionValue(ev) + } + + return unknownValue() +} + +func decodeEvidenceMap(evidence json.RawMessage) map[string]any { + if len(evidence) == 0 { + return nil + } + + var ev map[string]any + if err := json.Unmarshal(evidence, &ev); err != nil { + return nil + } + + return ev +} + +func onOffValue(on bool) DevicePostureValue { + if on { + return DevicePostureValue{Kind: DevicePostureValueKindOn} + } + + return DevicePostureValue{Kind: DevicePostureValueKindOff} +} + +func unknownValue() DevicePostureValue { + return DevicePostureValue{Kind: DevicePostureValueKindUnknown} +} + +func noneValue() DevicePostureValue { + return DevicePostureValue{Kind: DevicePostureValueKindNone} +} + +func configuredValue() DevicePostureValue { + return DevicePostureValue{Kind: DevicePostureValueKindConfigured} +} + +func textValue(text string) DevicePostureValue { + text = truncateValue(text, devicePostureValueTextMax) + if text == "" { + return unknownValue() + } + + return DevicePostureValue{ + Kind: DevicePostureValueKindText, + Text: text, + } +} + +func secondsValue(seconds int) DevicePostureValue { + return DevicePostureValue{ + Kind: DevicePostureValueKindSeconds, + Number: new(seconds), + } +} + +func minPasswordLengthValue(length int) DevicePostureValue { + return DevicePostureValue{ + Kind: DevicePostureValueKindMinPasswordLength, + Number: new(length), + } +} + +// backendOf returns the tool the agent used to gather the evidence. Checks that +// probe a single tool on every platform do not set it. +func backendOf(ev map[string]any) string { + return strings.ToLower(stringEvidence(ev, "backend")) +} + +// hasAnyKey discriminates platforms for checks where the agent sets no backend +// key but the key set itself is distinctive. +func hasAnyKey(ev map[string]any, keys ...string) bool { + for _, key := range keys { + if _, ok := ev[key]; ok { + return true + } + } + + return false +} + +func lowerStringEvidence(ev map[string]any, key string) string { + return strings.ToLower(stringEvidence(ev, key)) +} + +func stringEvidence(ev map[string]any, key string) string { + v, ok := ev[key] + if !ok || v == nil { + return "" + } + + switch typed := v.(type) { + case string: + return strings.TrimSpace(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case bool: + if typed { + return "true" + } + + return "false" + default: + return "" + } +} + +func boolEvidence(ev map[string]any, key string) (bool, bool) { + v, ok := ev[key] + if !ok || v == nil { + return false, false + } + + switch typed := v.(type) { + case bool: + return typed, true + case string: + switch strings.ToLower(strings.TrimSpace(typed)) { + case "1", "true", "yes", "on": + return true, true + case "0", "false", "no", "off": + return false, true + } + case float64: + return typed != 0, true + } + + return false, false +} + +func numberEvidence(ev map[string]any, key string) (int, bool) { + v, ok := ev[key] + if !ok || v == nil { + return 0, false + } + + switch typed := v.(type) { + case float64: + return int(typed), true + case int: + return typed, true + case string: + n, err := strconv.Atoi(strings.TrimSpace(typed)) + if err != nil { + return 0, false + } + + return n, true + } + + return 0, false +} + +func stringSliceEvidence(ev map[string]any, key string) []string { + v, ok := ev[key] + if !ok || v == nil { + return nil + } + + switch typed := v.(type) { + case []string: + out := make([]string, 0, len(typed)) + for _, item := range typed { + if s := strings.TrimSpace(item); s != "" { + out = append(out, s) + } + } + + return out + case []any: + out := make([]string, 0, len(typed)) + for _, item := range typed { + s, ok := item.(string) + if !ok { + continue + } + + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + + return out + default: + return nil + } +} + +// stringMapEvidence reads a per-subject map such as the Windows firewall +// "profiles" or the per-user screen lock "users". +func stringMapEvidence(ev map[string]any, key string) map[string]string { + v, ok := ev[key] + if !ok || v == nil { + return nil + } + + typed, ok := v.(map[string]any) + if !ok { + return nil + } + + out := make(map[string]string, len(typed)) + + for name, raw := range typed { + s, ok := raw.(string) + if !ok { + continue + } + + out[name] = strings.TrimSpace(s) + } + + return out +} + +// allValuesMatch reports whether the map is non-empty and every value equals +// want, case-insensitively. +func allValuesMatch(values map[string]string, want string) (allMatch bool, any bool) { + if len(values) == 0 { + return false, false + } + + for _, v := range values { + if !strings.EqualFold(v, want) { + return false, true + } + } + + return true, true +} + +func allEntriesMatch(values []string, want string) (allMatch bool, any bool) { + if len(values) == 0 { + return false, false + } + + for _, v := range values { + if !strings.EqualFold(v, want) { + return false, true + } + } + + return true, true +} + +func firstNonEmptyString(values ...string) string { + for _, v := range values { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + + return "" +} + +// parseLabeledInt finds lines like "Minimum password length: 8". +func parseLabeledInt(raw, label string) (int, bool) { + if raw == "" || label == "" { + return 0, false + } + + lower := strings.ToLower(raw) + label = strings.ToLower(label) + + idx := strings.Index(lower, label) + if idx < 0 { + return 0, false + } + + rest := raw[idx+len(label):] + rest = strings.TrimLeft(rest, " \t.:") + + return parseLeadingInt(rest) +} + +// parseAssignedInt finds assignments like "minpasswordlen=8". +func parseAssignedInt(raw, key string) (int, bool) { + if raw == "" || key == "" { + return 0, false + } + + lower := strings.ToLower(raw) + key = strings.ToLower(key) + "=" + + idx := strings.Index(lower, key) + if idx < 0 { + return 0, false + } + + return parseLeadingInt(raw[idx+len(key):]) +} + +func parseLeadingInt(s string) (int, bool) { + s = strings.TrimLeft(s, " \t") + if s == "" { + return 0, false + } + + end := 0 + for end < len(s) && s[end] >= '0' && s[end] <= '9' { + end++ + } + + if end == 0 { + return 0, false + } + + n, err := strconv.Atoi(s[:end]) + if err != nil { + return 0, false + } + + return n, true +} + +// truncateValue collapses whitespace and caps the length on a rune boundary so +// the result stays valid UTF-8 for JSON encoding. +func truncateValue(v string, max int) string { + v = strings.Join(strings.Fields(v), " ") + if len(v) <= max { + return v + } + + cut := max - len("…") + for cut > 0 && !utf8.RuneStart(v[cut]) { + cut-- + } + + return v[:cut] + "…" +} diff --git a/pkg/coredata/device_posture_value_checks.go b/pkg/coredata/device_posture_value_checks.go new file mode 100644 index 000000000..a28ed2a88 --- /dev/null +++ b/pkg/coredata/device_posture_value_checks.go @@ -0,0 +1,584 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "strings" +) + +// One parser per posture check. Each dispatches on the tool the agent used, and +// the tools are enumerated in pkg/deviceagent/checks — read those alongside +// these branches, since they define what each backend's output means. + +func parseOSVersionValue(ev map[string]any) DevicePostureValue { + return textValue( + firstNonEmptyString( + stringEvidence(ev, "product_version"), + stringEvidence(ev, "pretty_name"), + stringEvidence(ev, "version_id"), + stringEvidence(ev, "version"), + stringEvidence(ev, "caption"), + stringEvidence(ev, "release"), + ), + ) +} + +func parseDiskEncryptionValue(ev map[string]any) DevicePostureValue { + // Linux is the only platform reporting crypttab, so the key doubles as the + // platform discriminator. + if present, ok := boolEvidence(ev, "crypttab_present"); ok { + return parseLinuxDiskEncryptionValue(ev, present) + } + + raw := lowerStringEvidence(ev, "raw") + switch { + case raw == "": + return unknownValue() + case strings.Contains(raw, "filevault is on"): + return onOffValue(true) + case strings.Contains(raw, "filevault is off"): + return onOffValue(false) + case strings.Contains(raw, "percentage encrypted: 100"), + strings.Contains(raw, "fully encrypted"), + strings.Contains(raw, "protection on"): + return onOffValue(true) + case strings.Contains(raw, "percentage encrypted: 0"), + strings.Contains(raw, "fully decrypted"), + strings.Contains(raw, "protection off"): + return onOffValue(false) + case strings.Contains(raw, "components"): + // FreeBSD geli prints a "Name Status Components" table with one row per + // encrypted provider; ACTIVE is the only status meaning attached. + return onOffValue(strings.Contains(raw, "active")) + } + + return unknownValue() +} + +func parseLinuxDiskEncryptionValue( + ev map[string]any, + crypttabPresent bool, +) DevicePostureValue { + if crypttabPresent && len(stringSliceEvidence(ev, "crypttab_lines")) > 0 { + return onOffValue(true) + } + + if lsblk := stringEvidence(ev, "lsblk"); lsblk != "" { + return onOffValue(lsblkHasCryptDevice(lsblk)) + } + + return unknownValue() +} + +// lsblkHasCryptDevice reports whether `lsblk -o NAME,TYPE,... -r` listed a +// device of type crypt, which is how a LUKS mapping appears. +func lsblkHasCryptDevice(raw string) bool { + for line := range strings.SplitSeq(raw, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + if fields[1] == "crypt" { + return true + } + } + + return false +} + +func parseScreenLockValue(ev map[string]any) DevicePostureValue { + switch backendOf(ev) { + case "sysadminctl": + return parseDarwinScreenLockModeValue(ev) + case "gnome", "cinnamon", "mate", "ukui": + return boolKeyValue(ev, "lock_enabled") + case "kde": + return boolKeyValue(ev, "autolock") + case "xfce": + return boolKeyValue(ev, "enabled") + case "i3": + return parseI3ScreenLockValue(ev) + case "machine_policy": + return boolKeyValue(ev, "screen_saver_is_secure") + case "hkey_users": + return parseWindowsUserScreenLockValue(ev) + } + + // macOS falls back to the com.apple.screensaver defaults, which set no + // backend key. + if ask, ok := boolEvidence(ev, "ask_for_password"); ok { + if !ask { + return onOffValue(false) + } + + if delay, ok := numberEvidence(ev, "ask_for_password_delay"); ok { + return screenLockDelayValue(delay) + } + + return onOffValue(true) + } + + return unknownValue() +} + +func parseDarwinScreenLockModeValue(ev map[string]any) DevicePostureValue { + switch stringEvidence(ev, "mode") { + case "immediate": + return DevicePostureValue{Kind: DevicePostureValueKindImmediate} + case "off": + return onOffValue(false) + case "seconds": + if seconds, ok := numberEvidence(ev, "seconds"); ok { + return screenLockDelayValue(seconds) + } + + return onOffValue(true) + } + + return unknownValue() +} + +func parseI3ScreenLockValue(ev map[string]any) DevicePostureValue { + if stringEvidence(ev, "error") != "" { + return unknownValue() + } + + // No idle lock command in the config at all. + if stringEvidence(ev, "mechanism") == "" { + return onOffValue(false) + } + + if minutes, ok := numberEvidence(ev, "idle_minutes"); ok && minutes > 0 { + return secondsValue(minutes * 60) + } + + return onOffValue(true) +} + +func parseWindowsUserScreenLockValue(ev map[string]any) DevicePostureValue { + allSecure, any := allValuesMatch(stringMapEvidence(ev, "users"), "1") + if !any { + return unknownValue() + } + + return onOffValue(allSecure) +} + +func screenLockDelayValue(seconds int) DevicePostureValue { + if seconds <= 0 { + return DevicePostureValue{Kind: DevicePostureValueKindImmediate} + } + + return secondsValue(seconds) +} + +func parseFirewallValue(ev map[string]any) DevicePostureValue { + switch backendOf(ev) { + case "defaults": + return parseDarwinFirewallStateValue(ev) + case "socketfilterfw": + return parseSocketFilterFWValue(ev) + case "ufw": + return parseUFWValue(ev) + case "firewalld": + return parseFirewalldValue(ev) + case "nftables": + return parseNftablesValue(ev) + case "iptables": + return parseIptablesValue(ev) + case "get-netfirewallprofile": + return parseWindowsFirewallProfilesValue(ev) + case "netsh": + return parseNetshFirewallValue(ev) + } + + // FreeBSD pfctl sets no backend key. + raw := lowerStringEvidence(ev, "raw") + switch { + case strings.Contains(raw, "status: enabled"): + return onOffValue(true) + case strings.Contains(raw, "status: disabled"): + return onOffValue(false) + } + + return unknownValue() +} + +// parseDarwinFirewallStateValue reads com.apple.alf globalstate, where 1 blocks +// incoming connections and 2 blocks all but essential services. +func parseDarwinFirewallStateValue(ev map[string]any) DevicePostureValue { + switch stringEvidence(ev, "global_state") { + case "1", "2": + return onOffValue(true) + case "0": + return onOffValue(false) + } + + return unknownValue() +} + +func parseSocketFilterFWValue(ev map[string]any) DevicePostureValue { + raw := lowerStringEvidence(ev, "raw") + switch { + case strings.Contains(raw, "disabled"), strings.Contains(raw, "state = 0"): + return onOffValue(false) + case strings.Contains(raw, "enabled"), + strings.Contains(raw, "state = 1"), + strings.Contains(raw, "state = 2"): + return onOffValue(true) + } + + return unknownValue() +} + +// parseUFWValue reads `ufw status`. The whole "status: " phrase has to +// match: "inactive" contains "active", so a bare substring test reads a +// disabled firewall as enabled. +func parseUFWValue(ev map[string]any) DevicePostureValue { + raw := lowerStringEvidence(ev, "raw") + switch { + case strings.Contains(raw, "status: inactive"): + return onOffValue(false) + case strings.Contains(raw, "status: active"): + return onOffValue(true) + } + + return unknownValue() +} + +// parseFirewalldValue reads `firewall-cmd --state`, which prints "running" or +// "not running" — so the negative has to be tested first. +func parseFirewalldValue(ev map[string]any) DevicePostureValue { + raw := lowerStringEvidence(ev, "raw") + switch { + case strings.Contains(raw, "not running"): + return onOffValue(false) + case strings.Contains(raw, "running"): + return onOffValue(true) + } + + return unknownValue() +} + +func parseNftablesValue(ev map[string]any) DevicePostureValue { + if stringEvidence(ev, "error") != "" { + return unknownValue() + } + + excerpt := stringEvidence(ev, "rules_excerpt") + if excerpt == "" { + return unknownValue() + } + + return onOffValue(strings.Contains(excerpt, "chain ")) +} + +// parseIptablesValue reads the INPUT chain default policy. An ACCEPT policy +// carrying rules cannot be classified without modelling the whole chain, which +// is what the agent declines to do as well. +func parseIptablesValue(ev map[string]any) DevicePostureValue { + if stringEvidence(ev, "error") != "" { + return unknownValue() + } + + switch strings.ToUpper(stringEvidence(ev, "input_policy")) { + case "DROP", "REJECT": + return onOffValue(true) + case "ACCEPT": + if rules, ok := numberEvidence(ev, "input_rules"); ok && rules == 0 { + return onOffValue(false) + } + + return unknownValue() + } + + return unknownValue() +} + +func parseWindowsFirewallProfilesValue(ev map[string]any) DevicePostureValue { + allEnabled, any := allValuesMatch(stringMapEvidence(ev, "profiles"), "true") + if !any { + return unknownValue() + } + + return onOffValue(allEnabled) +} + +func parseNetshFirewallValue(ev map[string]any) DevicePostureValue { + allOn, any := allEntriesMatch(stringSliceEvidence(ev, "state_lines"), "on") + if !any { + return unknownValue() + } + + return onOffValue(allOn) +} + +func parseTimeSyncValue(ev map[string]any) DevicePostureValue { + raw := lowerStringEvidence(ev, "raw") + switch { + case raw == "": + return unknownValue() + case strings.Contains(raw, "ntpsynchronized=yes"): + return onOffValue(true) + case strings.Contains(raw, "ntpsynchronized=no"): + return onOffValue(false) + case strings.Contains(raw, "network time: on"): + return onOffValue(true) + case strings.Contains(raw, "network time: off"): + return onOffValue(false) + case strings.Contains(raw, "is not running"): + return onOffValue(false) + case strings.Contains(raw, "is running"): + return onOffValue(true) + case strings.Contains(raw, "local cmos clock"): + // Windows w32tm: the local clock is not a synchronisation source. + return onOffValue(false) + case strings.Contains(raw, "source:"): + return onOffValue(true) + } + + return unknownValue() +} + +func parseAutoUpdateValue(ev map[string]any) DevicePostureValue { + switch backendOf(ev) { + case "defaults": + return parseDarwinSoftwareUpdateValue(ev) + case "unattended-upgrades": + // Each APT periodic task is enabled with a quoted "1". + return onOffValue(strings.Contains(stringEvidence(ev, "raw"), `"1"`)) + case "dnf-automatic": + return unitStateValue(stringEvidence(ev, "state")) + } + + // The Windows Update policy read sets no backend key. + if hasAnyKey(ev, "no_auto_update", "au_options", "wuauserv") { + return parseWindowsAutoUpdateValue(ev) + } + + return unknownValue() +} + +// parseDarwinSoftwareUpdateValue collapses the five Software Update preferences +// into one value: any preference off makes automatic updates off. +func parseDarwinSoftwareUpdateValue(ev map[string]any) DevicePostureValue { + if len(stringSliceEvidence(ev, "disabled_keys")) > 0 { + return onOffValue(false) + } + + if len(stringSliceEvidence(ev, "indeterminate_keys")) > 0 { + return unknownValue() + } + + return onOffValue(true) +} + +func parseWindowsAutoUpdateValue(ev map[string]any) DevicePostureValue { + if stringEvidence(ev, "no_auto_update") == "1" { + return onOffValue(false) + } + + // AUOptions: 2 notifies only, 3 downloads, 4 downloads and installs, 5 + // delegates to local administrators. + switch stringEvidence(ev, "au_options") { + case "3", "4", "5": + return onOffValue(true) + case "2": + return onOffValue(false) + } + + // With no managed policy the value is whether the Windows Update service is + // running to apply the OS default. + switch stringEvidence(ev, "wuauserv") { + case "running": + return onOffValue(true) + case "stopped": + return onOffValue(false) + } + + return unknownValue() +} + +func parsePasswordPolicyValue(ev map[string]any) DevicePostureValue { + // Linux reads PASS_MIN_LEN from /etc/login.defs. + if minLen, ok := numberEvidence(ev, "pass_min_len_value"); ok { + return minPasswordLengthValue(minLen) + } + + if minLen, ok := numberEvidence(ev, "pass_min_len"); ok { + return minPasswordLengthValue(minLen) + } + + if parseError := lowerStringEvidence(ev, "parse_error"); parseError != "" { + if strings.Contains(parseError, "not set") { + return noneValue() + } + + return unknownValue() + } + + // Windows `net accounts`. + if minLen, ok := parseLabeledInt( + stringEvidence(ev, "raw"), + "minimum password length", + ); ok { + return minPasswordLengthValue(minLen) + } + + // FreeBSD /etc/login.conf. + if snippet := stringEvidence(ev, "login_conf_snippet"); snippet != "" { + if minLen, ok := parseAssignedInt(snippet, "minpasswordlen"); ok { + return minPasswordLengthValue(minLen) + } + + if strings.Contains(strings.ToLower(snippet), "passwordtime=") { + return configuredValue() + } + + return noneValue() + } + + // macOS pwpolicy returns the policy plist, which has no single figure. + if _, ok := ev["raw_truncated"]; ok { + raw := lowerStringEvidence(ev, "raw_truncated") + if raw == "" || strings.Contains(raw, "no account policies") { + return noneValue() + } + + return configuredValue() + } + + return unknownValue() +} + +// parseRemoteLoginValue reports whether remote login is reachable, so On is the +// insecure observation here. +func parseRemoteLoginValue(ev map[string]any) DevicePostureValue { + // Windows: fDenyTSConnections=1 refuses Terminal Server connections. + if deny, ok := boolEvidence(ev, "fdeny_ts_connections"); ok { + return onOffValue(!deny) + } + + // Linux reports the ssh unit state. + if _, ok := ev["is_active"]; ok { + return unitActiveValue(stringEvidence(ev, "is_active")) + } + + raw := lowerStringEvidence(ev, "raw") + switch { + case strings.Contains(raw, "remote login: on"): + return onOffValue(true) + case strings.Contains(raw, "remote login: off"): + return onOffValue(false) + case strings.Contains(raw, "is not running"): + return onOffValue(false) + case strings.Contains(raw, "is running"): + return onOffValue(true) + } + + return unknownValue() +} + +func parseMalwareProtectionValue(ev map[string]any) DevicePostureValue { + // Windows Defender. + if antivirus, ok := boolEvidence(ev, "antivirus_enabled"); ok { + realtime, _ := boolEvidence(ev, "real_time_protection") + service, _ := boolEvidence(ev, "am_service_enabled") + + return onOffValue(antivirus && (realtime || service)) + } + + // macOS XProtect. The plist path is never surfaced. + if engine := stringEvidence(ev, "engine"); engine != "" { + if strings.Contains(lowerStringEvidence(ev, "note"), "not found") { + return noneValue() + } + + if version := stringEvidence(ev, "version"); version != "" { + return textValue(engine + " " + version) + } + + return textValue(engine) + } + + // Linux endpoint agents. Running agents are the value; agents installed but + // not running mean the protection is off. + if _, ok := ev["active"]; ok { + if active := stringSliceEvidence(ev, "active"); len(active) > 0 { + return textValue(strings.Join(active, ", ")) + } + + if len(stringSliceEvidence(ev, "installed")) > 0 { + return onOffValue(false) + } + + return unknownValue() + } + + // FreeBSD clamav. + raw := lowerStringEvidence(ev, "raw") + switch { + case strings.Contains(raw, "is not running"): + return onOffValue(false) + case strings.Contains(raw, "is running"): + return onOffValue(true) + } + + if strings.Contains(lowerStringEvidence(ev, "note"), "not installed") { + return noneValue() + } + + return unknownValue() +} + +// unitStateValue maps `systemctl is-enabled` output. +func unitStateValue(state string) DevicePostureValue { + switch strings.ToLower(strings.TrimSpace(state)) { + case "": + return unknownValue() + case "enabled", "enabled-runtime": + return onOffValue(true) + default: + return onOffValue(false) + } +} + +// unitActiveValue maps `systemctl is-active` output. +func unitActiveValue(state string) DevicePostureValue { + switch strings.ToLower(strings.TrimSpace(state)) { + case "active", "activating": + return onOffValue(true) + case "inactive", "failed", "deactivating": + return onOffValue(false) + } + + return unknownValue() +} + +func boolKeyValue(ev map[string]any, key string) DevicePostureValue { + v, ok := boolEvidence(ev, key) + if !ok { + return unknownValue() + } + + return onOffValue(v) +} diff --git a/pkg/coredata/device_posture_value_kind.go b/pkg/coredata/device_posture_value_kind.go new file mode 100644 index 000000000..35f91a835 --- /dev/null +++ b/pkg/coredata/device_posture_value_kind.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata + +import ( + "encoding" + "fmt" +) + +// DevicePostureValueKind classifies the observed value of a posture check so +// clients can localize it. It says nothing about whether the observation is +// acceptable — rulesets own that verdict. +type DevicePostureValueKind string + +const ( + // DevicePostureValueKindOn and DevicePostureValueKindOff are boolean + // observations: the feature is turned on, or it is turned off. + DevicePostureValueKindOn DevicePostureValueKind = "ON" + DevicePostureValueKindOff DevicePostureValueKind = "OFF" + + // DevicePostureValueKindImmediate is a screen lock with no grace period. + DevicePostureValueKindImmediate DevicePostureValueKind = "IMMEDIATE" + + // DevicePostureValueKindSeconds carries a delay in Number. + DevicePostureValueKindSeconds DevicePostureValueKind = "SECONDS" + + // DevicePostureValueKindMinPasswordLength carries a character count in + // Number. + DevicePostureValueKindMinPasswordLength DevicePostureValueKind = "MIN_PASSWORD_LENGTH" + + // DevicePostureValueKindConfigured means a policy exists but its content + // could not be reduced to a single figure. + DevicePostureValueKindConfigured DevicePostureValueKind = "CONFIGURED" + + // DevicePostureValueKindNone means the host positively reported the + // absence of the thing being checked. + DevicePostureValueKindNone DevicePostureValueKind = "NONE" + + // DevicePostureValueKindText carries a literal in Text that needs no + // translation: an OS version, an engine name, a list of agents. + DevicePostureValueKindText DevicePostureValueKind = "TEXT" + + // DevicePostureValueKindUnknown means the evidence did not answer the + // question. It is never a guess. + DevicePostureValueKindUnknown DevicePostureValueKind = "UNKNOWN" +) + +var ( + _ fmt.Stringer = DevicePostureValueKind("") + _ encoding.TextMarshaler = DevicePostureValueKind("") + _ encoding.TextUnmarshaler = (*DevicePostureValueKind)(nil) +) + +func DevicePostureValueKinds() []DevicePostureValueKind { + return []DevicePostureValueKind{ + DevicePostureValueKindOn, + DevicePostureValueKindOff, + DevicePostureValueKindImmediate, + DevicePostureValueKindSeconds, + DevicePostureValueKindMinPasswordLength, + DevicePostureValueKindConfigured, + DevicePostureValueKindNone, + DevicePostureValueKindText, + DevicePostureValueKindUnknown, + } +} + +func (v DevicePostureValueKind) IsValid() bool { + switch v { + case + DevicePostureValueKindOn, + DevicePostureValueKindOff, + DevicePostureValueKindImmediate, + DevicePostureValueKindSeconds, + DevicePostureValueKindMinPasswordLength, + DevicePostureValueKindConfigured, + DevicePostureValueKindNone, + DevicePostureValueKindText, + DevicePostureValueKindUnknown: + return true + } + + return false +} + +func (v DevicePostureValueKind) String() string { + return string(v) +} + +func (v DevicePostureValueKind) MarshalText() ([]byte, error) { + return []byte(v.String()), nil +} + +func (v *DevicePostureValueKind) UnmarshalText(text []byte) error { + val := DevicePostureValueKind(text) + if !val.IsValid() { + return fmt.Errorf("invalid DevicePostureValueKind value: %q", string(text)) + } + + *v = val + + return nil +} diff --git a/pkg/coredata/device_posture_value_test.go b/pkg/coredata/device_posture_value_test.go new file mode 100644 index 000000000..512e1079b --- /dev/null +++ b/pkg/coredata/device_posture_value_test.go @@ -0,0 +1,1070 @@ +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package coredata_test + +import ( + "encoding/json" + "strings" + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" +) + +// devicePostureValueCase is one evidence fixture and the value it must yield. +// Fixtures are copied from real command output on each platform, since the +// parser dispatches on the tool that produced them. +type devicePostureValueCase struct { + name string + checkKey string + evidence map[string]any + wantKind coredata.DevicePostureValueKind + wantText string + wantNumber *int +} + +func TestParseDevicePostureValue_OSVersion(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "darwin sw_vers", + checkKey: "OS_VERSION", + evidence: map[string]any{ + "product_version": "15.4", + "build_version": "24E248", + }, + wantKind: coredata.DevicePostureValueKindText, + wantText: "15.4", + }, + { + name: "linux os-release prefers pretty name", + checkKey: "OS_VERSION", + evidence: map[string]any{ + "pretty_name": "Ubuntu 24.04.2 LTS", + "version_id": "24.04", + "id": "ubuntu", + }, + wantKind: coredata.DevicePostureValueKindText, + wantText: "Ubuntu 24.04.2 LTS", + }, + { + name: "read failure is unknown", + checkKey: "OS_VERSION", + evidence: map[string]any{"error": "sw_vers failed"}, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + }, + ) +} + +func TestParseDevicePostureValue_DiskEncryption(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "linux lsblk crypt mapping", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "crypttab_present": false, + "lsblk": "nvme0n1 disk\nnvme0n1p3 part crypto_LUKS\nnvme0n1p3_crypt crypt LVM2_member", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "linux no encrypted device", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "crypttab_present": false, + "lsblk": "nvme0n1 disk\nnvme0n1p2 part ext4 /", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "linux lsblk unavailable is unknown", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "crypttab_present": false, + "lsblk_error": "exec: \"lsblk\": executable file not found in $PATH", + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "windows manage-bde missing does not surface the note", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{"note": "manage-bde not found"}, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "freebsd geli active provider", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "raw": " Name Status Components\nada0p4.eli ACTIVE ada0p4", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + }, + ) +} + +func TestParseDevicePostureValue_ScreenLock(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "darwin sysadminctl delay", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "backend": "sysadminctl", + "mode": "seconds", + "seconds": float64(900), + "raw": "screenLock delay is 900 seconds", + }, + wantKind: coredata.DevicePostureValueKindSeconds, + wantNumber: new(900), + }, + { + name: "darwin sysadminctl off", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "backend": "sysadminctl", + "mode": "off", + "raw": "screenLock is off", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "darwin defaults zero delay is immediate", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "ask_for_password": "1", + "ask_for_password_delay": "0", + }, + wantKind: coredata.DevicePostureValueKindImmediate, + }, + { + name: "linux gnome lock enabled", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "backend": "gnome", + "schema": "org.gnome.desktop.screensaver", + "lock_enabled": "true", + "console_user": "alice", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "linux i3 idle lock", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "backend": "i3", + "mechanism": "xautolock", + "locker": "i3lock", + "idle_minutes": float64(10), + }, + wantKind: coredata.DevicePostureValueKindSeconds, + wantNumber: new(600), + }, + { + name: "windows one user hive insecure", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "backend": "hkey_users", + "users": map[string]any{ + "S-1-5-21-1004336348-1177238915-682003330-1001": "1", + "S-1-5-21-1004336348-1177238915-682003330-1002": "0", + }, + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "windows no user hive loaded", + checkKey: "SCREEN_LOCK", + evidence: map[string]any{ + "backend": "hkey_users", + "users": map[string]any{}, + "note": "no interactive user hives loaded", + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + }, + ) +} + +func TestParseDevicePostureValue_Firewall(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "linux iptables accept policy with rules is unknown", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "iptables", + "input_policy": "ACCEPT", + "input_rules": float64(7), + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "windows netsh without any state line is unknown", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "netsh", + "state_lines": []any{}, + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "no firewall tool found", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{"note": "no known firewall tool found"}, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "empty evidence is unknown", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{}, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + }, + ) +} + +func TestParseDevicePostureValue_TimeSync(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "linux timedatectl synchronized", + checkKey: "TIME_SYNC", + evidence: map[string]any{ + "raw": "Timezone=Europe/Paris\nLocalRTC=no\nCanNTP=yes\nNTP=yes\nNTPSynchronized=yes", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "linux timedatectl not synchronized", + checkKey: "TIME_SYNC", + evidence: map[string]any{ + "raw": "Timezone=Europe/Paris\nLocalRTC=no\nCanNTP=yes\nNTP=yes\nNTPSynchronized=no", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "windows w32tm with a real source", + checkKey: "TIME_SYNC", + evidence: map[string]any{ + "raw": "Leap Indicator: 0(no warning)\nStratum: 4 (secondary reference)\nSource: time.windows.com,0x8\nPoll Interval: 10", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "windows w32tm falling back to the local clock", + checkKey: "TIME_SYNC", + evidence: map[string]any{ + "raw": "Leap Indicator: 3(not synchronized)\nStratum: 0 (unspecified)\nSource: Local CMOS Clock\nPoll Interval: 10", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + // "is not running" contains "is running". + name: "freebsd ntpd stopped", + checkKey: "TIME_SYNC", + evidence: map[string]any{"raw": "ntpd is not running."}, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "timedatectl missing is unknown", + checkKey: "TIME_SYNC", + evidence: map[string]any{"note": "timedatectl not installed"}, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + }, + ) +} + +func TestParseDevicePostureValue_AutoUpdate(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "darwin every preference enabled", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "backend": "defaults", + "AutomaticCheckEnabled": map[string]any{ + "source": "system", + "value": "1", + "enabled": true, + }, + "AutomaticDownload": map[string]any{ + "source": "default", + "enabled": true, + }, + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "darwin one preference disabled", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "backend": "defaults", + "disabled_keys": []any{"AutomaticDownload"}, + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "darwin unreadable preference", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "backend": "defaults", + "indeterminate_keys": []any{"ConfigDataInstall"}, + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "linux unattended upgrades enabled", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "backend": "unattended-upgrades", + "raw": "APT::Periodic::Update-Package-Lists \"1\";\nAPT::Periodic::Unattended-Upgrade \"1\";\n", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "linux unattended upgrades disabled", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "backend": "unattended-upgrades", + "raw": "APT::Periodic::Update-Package-Lists \"0\";\nAPT::Periodic::Unattended-Upgrade \"0\";\n", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + // "disabled" contains "enabled". + name: "linux dnf automatic timer disabled", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "backend": "dnf-automatic", + "state": "disabled", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "windows policy installs automatically", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "no_auto_update": "0", + "au_options": "4", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "windows policy only notifies", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "no_auto_update": "", + "au_options": "2", + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "windows without policy falls back to the service", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "no_auto_update": "", + "au_options": "", + "wuauserv": "running", + }, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + name: "freebsd has no auto update mechanism", + checkKey: "AUTO_UPDATE", + evidence: map[string]any{ + "note": "FreeBSD relies on operator-driven freebsd-update", + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + }, + ) +} + +func TestParseDevicePostureValue_PasswordPolicy(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "linux login defs minimum length", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "pass_min_len": "12", + "pass_max_days": "90", + "pass_min_len_value": float64(12), + }, + wantKind: coredata.DevicePostureValueKindMinPasswordLength, + wantNumber: new(12), + }, + { + name: "linux login defs without a minimum", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "pass_min_len": "", + "pass_max_days": "99999", + "parse_error": "PASS_MIN_LEN not set", + }, + wantKind: coredata.DevicePostureValueKindNone, + }, + { + name: "windows net accounts minimum length", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "raw": "Force user logoff how long after time expires?: Never\nMinimum password age (days): 0\nMaximum password age (days): 42\nMinimum password length: 8\n", + }, + wantKind: coredata.DevicePostureValueKindMinPasswordLength, + wantNumber: new(8), + }, + { + name: "darwin pwpolicy without account policies", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "raw_truncated": "There are no account policies for all users.", + }, + wantKind: coredata.DevicePostureValueKindNone, + }, + { + name: "darwin pwpolicy with a policy plist", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "raw_truncated": "policyCategoryPasswordContent...", + }, + wantKind: coredata.DevicePostureValueKindConfigured, + }, + { + name: "freebsd login conf minimum length", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "login_conf_snippet": "default:\\\n\t:passwd_format=sha512:\\\n\t:minpasswordlen=10:\\\n\t:passwordtime=90d:", + }, + wantKind: coredata.DevicePostureValueKindMinPasswordLength, + wantNumber: new(10), + }, + { + name: "freebsd login conf without a policy", + checkKey: "PASSWORD_POLICY", + evidence: map[string]any{ + "login_conf_snippet": "default:\\\n\t:passwd_format=sha512:", + }, + wantKind: coredata.DevicePostureValueKindNone, + }, + }, + ) +} + +func TestParseDevicePostureValue_RemoteLogin(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "linux without an ssh unit", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"is_active": ""}, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "freebsd sshd running", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"raw": "sshd is running as pid 987."}, + wantKind: coredata.DevicePostureValueKindOn, + }, + { + // "is not running" contains "is running". + name: "freebsd sshd stopped", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"raw": "sshd is not running."}, + wantKind: coredata.DevicePostureValueKindOff, + }, + }, + ) +} + +func TestParseDevicePostureValue_MalwareProtection(t *testing.T) { + t.Parallel() + + runDevicePostureValueCases( + t, + []devicePostureValueCase{ + { + name: "darwin xprotect version never surfaces the plist path", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "engine": "XProtect", + "plist": "/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.meta.plist", + "version": "5260", + }, + wantKind: coredata.DevicePostureValueKindText, + wantText: "XProtect 5260", + }, + { + name: "darwin xprotect missing", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "engine": "XProtect", + "note": "XProtect.meta.plist not found in expected locations", + }, + wantKind: coredata.DevicePostureValueKindNone, + }, + { + name: "linux running agents", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "active": []any{"ClamAV", "osquery"}, + "installed": []any{}, + }, + wantKind: coredata.DevicePostureValueKindText, + wantText: "ClamAV, osquery", + }, + { + name: "linux agent installed but stopped", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "active": []any{}, + "installed": []any{"ClamAV"}, + }, + wantKind: coredata.DevicePostureValueKindOff, + }, + { + name: "linux no known agent", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "active": []any{}, + "installed": []any{}, + }, + wantKind: coredata.DevicePostureValueKindUnknown, + }, + { + name: "freebsd clamav not installed", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{"note": "clamav not installed"}, + wantKind: coredata.DevicePostureValueKindNone, + }, + }, + ) +} + +// devicePostureAgreementCase pairs evidence with the status the agent itself +// derived from it, copied from the branch in pkg/deviceagent/checks that emits +// that evidence. +type devicePostureAgreementCase struct { + name string + checkKey string + evidence map[string]any + agentStatus coredata.DevicePostureStatus +} + +// passingKindByCheckKey is the state a passing check observed. Remote login is +// the inverted one: a reachable SSH server is the finding, so PASS means OFF. +var passingKindByCheckKey = map[string]coredata.DevicePostureValueKind{ + "FIREWALL_ENABLED": coredata.DevicePostureValueKindOn, + "DISK_ENCRYPTION": coredata.DevicePostureValueKindOn, + "TIME_SYNC": coredata.DevicePostureValueKindOn, + "MALWARE_PROTECTION": coredata.DevicePostureValueKindOn, + "REMOTE_LOGIN": coredata.DevicePostureValueKindOff, +} + +// TestParseDevicePostureValue_AgreesWithAgentStatus is the regression guard for +// the whole parser. For a check whose value is a state rather than a +// measurement, the agent's PASS/FAIL and the parsed ON/OFF read the same bit +// out of the same evidence, so the two cannot disagree without one of them +// being wrong. Reading `ufw status` with a bare "active" substring test used to +// report a disabled firewall as ON against the agent's own FAIL. +func TestParseDevicePostureValue_AgreesWithAgentStatus(t *testing.T) { + t.Parallel() + + cases := []devicePostureAgreementCase{ + { + name: "darwin alf blocks incoming", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "defaults", + "global_state": "1", + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "darwin alf off", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "defaults", + "global_state": "0", + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "darwin socketfilterfw enabled", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "socketfilterfw", + "raw": "Firewall is enabled. (State = 1)", + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "darwin socketfilterfw disabled", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "socketfilterfw", + "raw": "Firewall is disabled. (State = 0)", + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "linux ufw active", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "ufw", + "raw": "Status: active", + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "linux ufw inactive", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "ufw", + "raw": "Status: inactive", + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "linux firewalld running", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{"backend": "firewalld", "raw": "running"}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "linux firewalld not running", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{"backend": "firewalld", "raw": "not running"}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "linux nftables with a chain", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "nftables", + "rules_excerpt": "table inet filter {\n\tchain input {\n\t\tpolicy drop;\n\t}\n}", + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "linux nftables empty ruleset", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "nftables", + "rules_excerpt": "table inet filter {\n}", + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "linux iptables drop policy", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "iptables", + "input_policy": "DROP", + "input_rules": float64(4), + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "linux iptables accept policy without rules", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "iptables", + "input_policy": "ACCEPT", + "input_rules": float64(0), + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "windows every profile enabled", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "Get-NetFirewallProfile", + "raw": "Domain=True;Private=True;Public=True", + "profiles": map[string]any{ + "Domain": "True", + "Private": "True", + "Public": "True", + }, + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "windows public profile disabled", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "Get-NetFirewallProfile", + "raw": "Domain=True;Private=True;Public=False", + "profiles": map[string]any{ + "Domain": "True", + "Private": "True", + "Public": "False", + }, + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "windows netsh every profile on", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "netsh", + "state_lines": []any{"on", "on"}, + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "windows netsh one profile off", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{ + "backend": "netsh", + "state_lines": []any{"on", "off"}, + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "freebsd pfctl enabled", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{"raw": "Status: Enabled for 3 days 04:21:16"}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "freebsd pfctl disabled", + checkKey: "FIREWALL_ENABLED", + evidence: map[string]any{"raw": "Status: Disabled"}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "darwin filevault on", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{"raw": "FileVault is On."}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "darwin filevault off", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{"raw": "FileVault is Off."}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "linux crypttab with an entry", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "crypttab_present": true, + "crypttab_lines": []any{ + "nvme0n1p3_crypt UUID=6c2f none luks,discard", + }, + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "windows bitlocker fully encrypted", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "raw": "Conversion Status: Fully Encrypted\n Percentage Encrypted: 100%", + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "windows bitlocker fully decrypted", + checkKey: "DISK_ENCRYPTION", + evidence: map[string]any{ + "raw": "Conversion Status: Fully Decrypted\n Percentage Encrypted: 0%", + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "darwin network time on", + checkKey: "TIME_SYNC", + evidence: map[string]any{"raw": "Network Time: On"}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "darwin network time off", + checkKey: "TIME_SYNC", + evidence: map[string]any{"raw": "Network Time: Off"}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "darwin remote login off", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"raw": "Remote Login: Off"}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "darwin remote login on", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"raw": "Remote Login: On"}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "linux sshd inactive", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"is_active": "inactive"}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "linux sshd active", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"is_active": "active"}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "windows terminal services denied", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"fdeny_ts_connections": "1"}, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "windows terminal services allowed", + checkKey: "REMOTE_LOGIN", + evidence: map[string]any{"fdeny_ts_connections": "0"}, + agentStatus: coredata.DevicePostureStatusFail, + }, + { + name: "windows defender with real time protection", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "antivirus_enabled": true, + "real_time_protection": true, + "am_service_enabled": true, + }, + agentStatus: coredata.DevicePostureStatusPass, + }, + { + name: "windows defender disabled", + checkKey: "MALWARE_PROTECTION", + evidence: map[string]any{ + "antivirus_enabled": false, + "real_time_protection": false, + "am_service_enabled": false, + }, + agentStatus: coredata.DevicePostureStatusFail, + }, + } + + for _, tt := range cases { + t.Run(tt.checkKey+" "+tt.name, func(t *testing.T) { + t.Parallel() + + passingKind, ok := passingKindByCheckKey[tt.checkKey] + require.True(t, ok, "no passing kind declared for %s", tt.checkKey) + + wantKind := passingKind + if tt.agentStatus == coredata.DevicePostureStatusFail { + wantKind = oppositeDevicePostureValueKind(passingKind) + } + + raw, err := json.Marshal(tt.evidence) + require.NoError(t, err) + + value := coredata.ParseDevicePostureValue(tt.checkKey, raw) + + assert.Equal( + t, + wantKind, + value.Kind, + "the agent read this evidence as %s, so the value must be %s", + tt.agentStatus, + wantKind, + ) + }) + } +} + +func oppositeDevicePostureValueKind( + kind coredata.DevicePostureValueKind, +) coredata.DevicePostureValueKind { + if kind == coredata.DevicePostureValueKindOn { + return coredata.DevicePostureValueKindOff + } + + return coredata.DevicePostureValueKindOn +} + +func TestParseDevicePostureValue_UnknownCheckKey(t *testing.T) { + t.Parallel() + + value := coredata.ParseDevicePostureValue( + "SOMETHING_NEW", + json.RawMessage(`{"raw":"whatever the agent sent"}`), + ) + + assert.Equal(t, coredata.DevicePostureValueKindUnknown, value.Kind) + assert.Empty(t, value.Text) +} + +func TestParseDevicePostureValue_MalformedEvidence(t *testing.T) { + t.Parallel() + + for name, evidence := range map[string]string{ + "empty": "", + "empty object": "{}", + "not an object": `["raw"]`, + "invalid json": `{"raw":`, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + value := coredata.ParseDevicePostureValue( + "DISK_ENCRYPTION", + json.RawMessage(evidence), + ) + + assert.Equal(t, coredata.DevicePostureValueKindUnknown, value.Kind) + }) + } +} + +// TestParseDevicePostureValue_NeverSurfacesRawEvidence guards the privacy +// property of the parser: evidence carries usernames, file paths and command +// output, and none of it may reach the value. Anything the parser cannot +// classify is UNKNOWN. +func TestParseDevicePostureValue_NeverSurfacesRawEvidence(t *testing.T) { + t.Parallel() + + secrets := []string{"alice", "/home/alice", "manage-bde", "netsh", "gsettings"} + + evidences := map[string]map[string]any{ + "linux screen lock with console user": { + "backend": "gnome", + "schema": "org.gnome.desktop.screensaver", + "lock_enabled": "true", + "console_user": "alice", + }, + "linux screen lock with an unreadable schema": { + "backend": "gsettings", + "console_user": "alice", + }, + "linux i3 config path": { + "backend": "i3", + "config": "/home/alice/.config/i3/config", + "console_user": "alice", + "mechanism": "xautolock", + "locker": "i3lock", + "idle_minutes": float64(5), + }, + "windows bitlocker unavailable": { + "note": "manage-bde not found", + }, + "windows firewall via netsh": { + "backend": "netsh", + "raw": "State ON", + "state_lines": []any{"on"}, + }, + } + + for name, evidence := range evidences { + t.Run(name, func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(evidence) + require.NoError(t, err) + + for _, checkKey := range coredata.DevicePostureCheckKeys() { + value := coredata.ParseDevicePostureValue(checkKey.String(), raw) + + for _, secret := range secrets { + assert.NotContains( + t, + value.Text, + secret, + "check %s leaked evidence into the value", + checkKey, + ) + } + } + }) + } +} + +// TestParseDevicePostureValue_TextIsValidUTF8 covers the truncation boundary: +// cutting a long value mid-rune would produce invalid UTF-8 and break JSON +// encoding of the response. +func TestParseDevicePostureValue_TextIsValidUTF8(t *testing.T) { + t.Parallel() + + evidence, err := json.Marshal( + map[string]any{"pretty_name": strings.Repeat("é", 200)}, + ) + require.NoError(t, err) + + value := coredata.ParseDevicePostureValue("OS_VERSION", evidence) + + require.Equal(t, coredata.DevicePostureValueKindText, value.Kind) + assert.True(t, utf8.ValidString(value.Text)) + assert.LessOrEqual(t, len(value.Text), 80) +} + +func runDevicePostureValueCases(t *testing.T, cases []devicePostureValueCase) { + t.Helper() + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + raw, err := json.Marshal(tt.evidence) + require.NoError(t, err) + + value := coredata.ParseDevicePostureValue(tt.checkKey, raw) + + assert.Equal(t, tt.wantKind, value.Kind) + assert.Equal(t, tt.wantText, value.Text) + + if tt.wantNumber == nil { + assert.Nil(t, value.Number) + + return + } + + require.NotNil(t, value.Number) + assert.Equal(t, *tt.wantNumber, *value.Number) + }) + } +} diff --git a/pkg/coredata/entity_type_reg.go b/pkg/coredata/entity_type_reg.go index b8dcc9dc0..287f089ac 100644 --- a/pkg/coredata/entity_type_reg.go +++ b/pkg/coredata/entity_type_reg.go @@ -139,6 +139,7 @@ const ( DeviceEntityType uint16 = 107 DevicePostureEntityType uint16 = 108 DeviceEnrollmentTokenEntityType uint16 = 109 + DevicePostureReportEntityType uint16 = 110 ) func NewEntityFromID(id gid.GID) (any, bool) { @@ -345,6 +346,8 @@ func NewEntityFromID(id gid.GID) (any, bool) { return &DevicePosture{ID: id}, true case DeviceEnrollmentTokenEntityType: return &DeviceEnrollmentToken{ID: id}, true + case DevicePostureReportEntityType: + return &DevicePostureReport{ID: id}, true default: return nil, false } diff --git a/pkg/coredata/migrations/20260728T174416Z.sql b/pkg/coredata/migrations/20260728T174416Z.sql new file mode 100644 index 000000000..301e28e21 --- /dev/null +++ b/pkg/coredata/migrations/20260728T174416Z.sql @@ -0,0 +1,48 @@ +-- Copyright (c) 2026 Probo Inc . +-- +-- Permission is hereby granted, free of charge, to any person obtaining a copy +-- of this software and associated documentation files (the "Software"), to deal +-- in the Software without restriction, including without limitation the rights +-- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +-- copies of the Software, and to permit persons to whom the Software is +-- furnished to do so, subject to the following conditions: +-- +-- The above copyright notice and this permission notice shall be included in +-- all copies or substantial portions of the Software. +-- +-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +-- SOFTWARE. + +ALTER TABLE device_postures + ADD COLUMN correlation_id TEXT; + +WITH report_ids AS ( + SELECT + device_id, + created_at, + generate_gid(parse_tenant_id(MIN(tenant_id)), 110) AS correlation_id + FROM + device_postures + GROUP BY + device_id, + created_at +) +UPDATE device_postures AS dp +SET + correlation_id = report_ids.correlation_id +FROM + report_ids +WHERE + dp.device_id = report_ids.device_id + AND dp.created_at = report_ids.created_at; + +ALTER TABLE device_postures + ALTER COLUMN correlation_id SET NOT NULL; + +CREATE INDEX device_postures_device_id_correlation_id_created_at_idx + ON device_postures (device_id, correlation_id, created_at DESC); diff --git a/pkg/deviceagent/agent.go b/pkg/deviceagent/agent.go index b3015b00d..755cded33 100644 --- a/pkg/deviceagent/agent.go +++ b/pkg/deviceagent/agent.go @@ -28,8 +28,10 @@ import ( "time" "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/deviceagent/checks" "go.probo.inc/probo/pkg/deviceagent/update" + "go.probo.inc/probo/pkg/gid" ) const ( @@ -450,15 +452,27 @@ func (a *Agent) doPostures(ctx context.Context) { log.Duration("per_check_timeout", perCheckTimeout), ) + deviceID, err := gid.ParseGID(a.cfg.DeviceID) + if err != nil { + a.Logger.ErrorCtx(ctx, "cannot parse device id for posture correlation", log.Error(err)) + return + } + + correlationID := gid.New( + deviceID.TenantID(), + coredata.DevicePostureReportEntityType, + ).String() + payload := make([]PostureResultPayload, 0, len(results)) for _, r := range results { payload = append( payload, PostureResultPayload{ - CheckKey: r.CheckKey, - Status: string(r.Status), - Evidence: checks.EvidenceJSON(r.Evidence), - ObservedAt: r.ObservedAt, + CheckKey: r.CheckKey, + Status: string(r.Status), + Evidence: checks.EvidenceJSON(r.Evidence), + ObservedAt: r.ObservedAt, + CorrelationID: correlationID, }, ) } diff --git a/pkg/deviceagent/client.go b/pkg/deviceagent/client.go index c259a9752..e2bdcb12d 100644 --- a/pkg/deviceagent/client.go +++ b/pkg/deviceagent/client.go @@ -75,10 +75,11 @@ type ( } PostureResultPayload struct { - CheckKey string `json:"check_key"` - Status string `json:"status"` - Evidence json.RawMessage `json:"evidence,omitempty"` - ObservedAt time.Time `json:"observed_at"` + CheckKey string `json:"check_key"` + Status string `json:"status"` + Evidence json.RawMessage `json:"evidence,omitempty"` + ObservedAt time.Time `json:"observed_at"` + CorrelationID string `json:"correlation_id"` } PosturesRequest struct { diff --git a/pkg/itam/service.go b/pkg/itam/service.go index c06ff422a..93c800d04 100644 --- a/pkg/itam/service.go +++ b/pkg/itam/service.go @@ -57,6 +57,18 @@ var ( // ErrEnrollmentTokenInvalid is returned when an enrollment token // cannot be exchanged for the device. ErrEnrollmentTokenInvalid = errors.New("enrollment token invalid") + + // ErrCorrelationIDRequired is returned when a posture result is + // missing a correlation ID. + ErrCorrelationIDRequired = errors.New("correlation_id is required") + + // ErrInvalidCorrelationIDEntityType is returned when a posture + // correlation ID is not a DevicePostureReport entity. + ErrInvalidCorrelationIDEntityType = errors.New("correlation_id entity type is invalid") + + // ErrInvalidCorrelationIDTenant is returned when a posture + // correlation ID belongs to a different tenant than the device. + ErrInvalidCorrelationIDTenant = errors.New("correlation_id tenant is invalid") ) const ( @@ -109,10 +121,11 @@ type ( } RecordPostureResult struct { - CheckKey string - Status coredata.DevicePostureStatus - Evidence json.RawMessage - ObservedAt time.Time + CheckKey string + Status coredata.DevicePostureStatus + Evidence json.RawMessage + ObservedAt time.Time + CorrelationID gid.GID } ServiceConfig struct { @@ -670,6 +683,113 @@ func (s *Service) GetPostureHistory( return postures, nil } +func (s *Service) ListPostureReports( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, + cursor *page.Cursor[coredata.DevicePostureReportOrderField], +) (*page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField], error) { + var result *page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField] + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var reports coredata.DevicePostureReports + if err := reports.LoadByDeviceID(ctx, conn, scope, deviceID, cursor); err != nil { + return fmt.Errorf("cannot load device posture reports: %w", err) + } + + p := page.NewPage(reports, cursor) + if err := attachPosturesToReports(ctx, conn, scope, deviceID, p.Data); err != nil { + return err + } + + result = p + + return nil + }, + ) + if err != nil { + return nil, err + } + + return result, nil +} + +func (s *Service) CountPostureReports( + ctx context.Context, + scope coredata.Scoper, + deviceID gid.GID, +) (int, error) { + var count int + + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + var reports coredata.DevicePostureReports + + n, err := reports.CountByDeviceID(ctx, conn, scope, deviceID) + if err != nil { + return fmt.Errorf("cannot count device posture reports: %w", err) + } + + count = n + + return nil + }, + ) + if err != nil { + return 0, err + } + + return count, nil +} + +func attachPosturesToReports( + ctx context.Context, + conn pg.Querier, + scope coredata.Scoper, + deviceID gid.GID, + reports []*coredata.DevicePostureReport, +) error { + if len(reports) == 0 { + return nil + } + + correlationIDs := make([]gid.GID, len(reports)) + for i, report := range reports { + correlationIDs[i] = report.ID + report.Postures = nil + } + + var postures coredata.DevicePostures + if err := postures.LoadByDeviceIDAndCorrelationIDs( + ctx, + conn, + scope, + deviceID, + correlationIDs, + ); err != nil { + return fmt.Errorf("cannot load postures for reports: %w", err) + } + + reportsByID := make(map[gid.GID]*coredata.DevicePostureReport, len(reports)) + for _, report := range reports { + reportsByID[report.ID] = report + } + + for _, posture := range postures { + report, ok := reportsByID[posture.CorrelationID] + if !ok { + continue + } + + report.Postures = append(report.Postures, posture) + } + + return nil +} + // AuthenticateDevice resolves a device API key to its device row. // Returns coredata.ErrResourceNotFound when no non-revoked device // matches the key. Revoked devices are treated as not found. @@ -811,10 +931,23 @@ func (s *Service) RecordPostures( } for _, r := range results { + if r.CorrelationID == gid.Nil { + return ErrCorrelationIDRequired + } + + if r.CorrelationID.EntityType() != coredata.DevicePostureReportEntityType { + return ErrInvalidCorrelationIDEntityType + } + + if r.CorrelationID.TenantID() != device.ID.TenantID() { + return ErrInvalidCorrelationIDTenant + } + posture := coredata.DevicePosture{ ID: gid.New(device.OrganizationID.TenantID(), coredata.DevicePostureEntityType), OrganizationID: device.OrganizationID, DeviceID: device.ID, + CorrelationID: r.CorrelationID, CheckKey: r.CheckKey, Status: r.Status, Evidence: r.Evidence, diff --git a/pkg/server/api/agent/v1/agent.go b/pkg/server/api/agent/v1/agent.go index a32b54213..9beb3f781 100644 --- a/pkg/server/api/agent/v1/agent.go +++ b/pkg/server/api/agent/v1/agent.go @@ -36,6 +36,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/bearertoken" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/server/api/agent/v1/types" "go.probo.inc/probo/pkg/server/jsonx" @@ -177,15 +178,33 @@ func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) { return } + fallbackCorrelationID := gid.New( + dev.ID.TenantID(), + coredata.DevicePostureReportEntityType, + ) + results := make([]itam.RecordPostureResult, 0, len(req.Results)) for _, pr := range req.Results { + correlationID := fallbackCorrelationID + + if pr.CorrelationID != "" { + parsed, err := gid.ParseGID(pr.CorrelationID) + if err != nil { + jsonx.RenderBadRequest(w, errors.New("correlation_id is invalid")) + return + } + + correlationID = parsed + } + results = append( results, itam.RecordPostureResult{ - CheckKey: pr.CheckKey, - Status: pr.Status, - Evidence: pr.Evidence, - ObservedAt: pr.ObservedAt, + CheckKey: pr.CheckKey, + Status: pr.Status, + Evidence: pr.Evidence, + ObservedAt: pr.ObservedAt, + CorrelationID: correlationID, }, ) } @@ -198,6 +217,13 @@ func (h *Handler) handlePostures(w http.ResponseWriter, r *http.Request) { return } + if errors.Is(err, itam.ErrCorrelationIDRequired) || + errors.Is(err, itam.ErrInvalidCorrelationIDEntityType) || + errors.Is(err, itam.ErrInvalidCorrelationIDTenant) { + jsonx.RenderBadRequest(w, err) + return + } + h.logger.ErrorCtx(r.Context(), "cannot record postures", log.Error(err)) jsonx.RenderInternalServerError(w) diff --git a/pkg/server/api/agent/v1/types/models.go b/pkg/server/api/agent/v1/types/models.go index 55896c7dd..dc1427482 100644 --- a/pkg/server/api/agent/v1/types/models.go +++ b/pkg/server/api/agent/v1/types/models.go @@ -52,10 +52,11 @@ type ( } PostureResultPayload struct { - CheckKey string `json:"check_key"` - Status coredata.DevicePostureStatus `json:"status"` - Evidence json.RawMessage `json:"evidence,omitempty"` - ObservedAt time.Time `json:"observed_at"` + CheckKey string `json:"check_key"` + Status coredata.DevicePostureStatus `json:"status"` + Evidence json.RawMessage `json:"evidence,omitempty"` + ObservedAt time.Time `json:"observed_at"` + CorrelationID string `json:"correlation_id"` } PostureRequest struct { diff --git a/pkg/server/api/console/v1/device_resolvers.go b/pkg/server/api/console/v1/device_resolvers.go index 8fad3eccc..5590f854e 100644 --- a/pkg/server/api/console/v1/device_resolvers.go +++ b/pkg/server/api/console/v1/device_resolvers.go @@ -14,6 +14,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/itam" + "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authz" "go.probo.inc/probo/pkg/server/api/console/v1/dataloader" @@ -64,6 +65,36 @@ func (r *deviceResolver) LatestPostures(ctx context.Context, obj *types.Device) return types.NewDevicePostures(postures), nil } +// PostureReports is the resolver for the postureReports field. +func (r *deviceResolver) PostureReports(ctx context.Context, obj *types.Device, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.DevicePostureReportOrderBy) (*types.DevicePostureReportConnection, error) { + scope, err := r.authorize(ctx, obj.ID, itam.ActionDevicePostureList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.DevicePostureReportOrderField]{ + Field: coredata.DevicePostureReportOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.DevicePostureReportOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + p, err := r.itam.ListPostureReports(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list device posture reports", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return types.NewDevicePostureReportConnection(p, r, obj.ID), nil +} + // TotalCount is the resolver for the DeviceConnection.totalCount field. func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.DeviceConnection) (int, error) { scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDeviceList) @@ -85,6 +116,23 @@ func (r *deviceConnectionResolver) TotalCount(ctx context.Context, obj *types.De return 0, gqlutils.Internal(ctx) } +// TotalCount is the resolver for the totalCount field. +func (r *devicePostureReportConnectionResolver) TotalCount(ctx context.Context, obj *types.DevicePostureReportConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, itam.ActionDevicePostureList) + if err != nil { + return 0, err + } + + count, err := r.itam.CountPostureReports(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count device posture reports", log.Error(err)) + + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + // EnrollDevice is the resolver for the enrollDevice field. // SkipAssumptionCheck: self-enrollment from /enroll runs before the viewer // assumes the target organization. @@ -220,7 +268,13 @@ func (r *Resolver) DeviceConnection() schema.DeviceConnectionResolver { return &deviceConnectionResolver{r} } +// DevicePostureReportConnection returns schema.DevicePostureReportConnectionResolver implementation. +func (r *Resolver) DevicePostureReportConnection() schema.DevicePostureReportConnectionResolver { + return &devicePostureReportConnectionResolver{r} +} + type ( - deviceResolver struct{ *Resolver } - deviceConnectionResolver struct{ *Resolver } + deviceResolver struct{ *Resolver } + deviceConnectionResolver struct{ *Resolver } + devicePostureReportConnectionResolver struct{ *Resolver } ) diff --git a/pkg/server/api/console/v1/graphql/device.graphql b/pkg/server/api/console/v1/graphql/device.graphql index 61deb3d67..64d727d8b 100644 --- a/pkg/server/api/console/v1/graphql/device.graphql +++ b/pkg/server/api/console/v1/graphql/device.graphql @@ -95,6 +95,55 @@ type Device implements Node { owner: Profile @goField(forceResolver: true) latestPostures: [DevicePosture!]! @goField(forceResolver: true) + postureReports( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: DevicePostureReportOrder + ): DevicePostureReportConnection! @goField(forceResolver: true) +} + +enum DevicePostureValueKind + @goModel( + model: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKind" + ) { + ON @goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindOn") + OFF + @goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindOff") + IMMEDIATE + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindImmediate" + ) + SECONDS + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindSeconds" + ) + MIN_PASSWORD_LENGTH + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindMinPasswordLength" + ) + CONFIGURED + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindConfigured" + ) + NONE + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindNone" + ) + TEXT + @goEnum(value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindText") + UNKNOWN + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureValueKindUnknown" + ) +} + +type DevicePostureValue + @goModel(model: "go.probo.inc/probo/pkg/coredata.DevicePostureValue") { + kind: DevicePostureValueKind! + text: String! + number: Int } type DevicePosture implements Node { @@ -102,9 +151,48 @@ type DevicePosture implements Node { deviceId: ID! checkKey: String! status: DevicePostureStatus! + value: DevicePostureValue! observedAt: Datetime! } +enum DevicePostureReportOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.DevicePostureReportOrderField" + ) { + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.DevicePostureReportOrderFieldCreatedAt" + ) +} + +input DevicePostureReportOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DevicePostureReportOrderBy" + ) { + direction: OrderDirection! + field: DevicePostureReportOrderField! +} + +type DevicePostureReport { + id: ID! + createdAt: Datetime! + postures: [DevicePosture!]! +} + +type DevicePostureReportConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DevicePostureReportConnection" + ) { + edges: [DevicePostureReportEdge!]! + pageInfo: PageInfo! + totalCount: Int! @goField(forceResolver: true) +} + +type DevicePostureReportEdge { + cursor: CursorKey! + node: DevicePostureReport! +} + type DeviceConnection @goModel( model: "go.probo.inc/probo/pkg/server/api/console/v1/types.DeviceConnection" diff --git a/pkg/server/api/console/v1/types/device.go b/pkg/server/api/console/v1/types/device.go index 514353db5..b7f1db325 100644 --- a/pkg/server/api/console/v1/types/device.go +++ b/pkg/server/api/console/v1/types/device.go @@ -27,7 +27,8 @@ import ( ) type ( - DeviceOrderBy OrderBy[coredata.DeviceOrderField] + DeviceOrderBy OrderBy[coredata.DeviceOrderField] + DevicePostureReportOrderBy OrderBy[coredata.DevicePostureReportOrderField] DeviceConnection struct { TotalCount int @@ -47,6 +48,15 @@ type ( Cursor page.CursorKey Node *Device } + + DevicePostureReportConnection struct { + TotalCount int + Edges []*DevicePostureReportEdge + PageInfo PageInfo + + Resolver any + ParentID gid.GID + } ) func NewDeviceConnection( @@ -122,11 +132,14 @@ func NewDevice(d *coredata.Device) *Device { } func NewDevicePosture(p *coredata.DevicePosture) *DevicePosture { + value := coredata.ParseDevicePostureValue(p.CheckKey, p.Evidence) + return &DevicePosture{ ID: p.ID, DeviceID: p.DeviceID, CheckKey: p.CheckKey, Status: p.Status, + Value: &value, ObservedAt: p.ObservedAt, } } @@ -139,3 +152,41 @@ func NewDevicePostures(ps coredata.DevicePostures) []*DevicePosture { return out } + +func NewDevicePostureReport( + s *coredata.DevicePostureReport, +) *DevicePostureReport { + return &DevicePostureReport{ + ID: s.ID, + CreatedAt: s.CreatedAt, + Postures: NewDevicePostures(s.Postures), + } +} + +func NewDevicePostureReportEdge( + s *coredata.DevicePostureReport, + orderBy coredata.DevicePostureReportOrderField, +) *DevicePostureReportEdge { + return &DevicePostureReportEdge{ + Cursor: s.CursorKey(orderBy), + Node: NewDevicePostureReport(s), + } +} + +func NewDevicePostureReportConnection( + p *page.Page[*coredata.DevicePostureReport, coredata.DevicePostureReportOrderField], + parentType any, + parentID gid.GID, +) *DevicePostureReportConnection { + edges := make([]*DevicePostureReportEdge, len(p.Data)) + for i := range edges { + edges[i] = NewDevicePostureReportEdge(p.Data[i], p.Cursor.OrderBy.Field) + } + + return &DevicePostureReportConnection{ + Edges: edges, + PageInfo: *NewPageInfo(p), + Resolver: parentType, + ParentID: parentID, + } +}