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, + } +}