Show posture values and report history
Pass/fail was the main device UI signal, but operators need the agent's observed value. Expose a formatted value per check, show current postures on the device page, and replace the Postures tab with paginated report history grouped by agent push time. Status stays in the model for later rulesets. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
<PageHeader
|
||||
title={hostnameLabel}
|
||||
description={displayValue(device.platform, pendingLabel)}
|
||||
>
|
||||
<PageHeader title={hostnameLabel}>
|
||||
{!isRevoked && canRevokeDevice && (
|
||||
<Button
|
||||
variant="danger"
|
||||
@@ -120,15 +112,14 @@ export function DeviceLayout({ queryRef }: DeviceLayoutProps) {
|
||||
|
||||
<DeviceDetailsCard deviceFragmentRef={device} />
|
||||
|
||||
<Tabs>
|
||||
<TabLink
|
||||
to={`/organizations/${organizationId}/devices/${device.id}/postures`}
|
||||
>
|
||||
{t("devices.postures.navigation")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
<DeviceCurrentPostures deviceFragmentRef={device} />
|
||||
|
||||
<Outlet />
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">
|
||||
{t("devices.history.title")}
|
||||
</h2>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DeviceRow } from "./_components/DeviceRow";
|
||||
import { PostureColumnHeader } from "./_components/PostureColumnHeader";
|
||||
import { CreateDeviceDialog } from "./dialogs/CreateDeviceDialog";
|
||||
|
||||
export const devicesPageQuery = graphql`
|
||||
@@ -141,9 +140,6 @@ export function DevicesPage({ queryRef }: DevicesPageProps) {
|
||||
<Th>{t("devices.fields.platform")}</Th>
|
||||
<Th>{t("devices.fields.osVersion")}</Th>
|
||||
<SortableTh field="LAST_SEEN_AT">{t("devices.fields.lastSeen")}</SortableTh>
|
||||
<Th>
|
||||
<PostureColumnHeader />
|
||||
</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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 { Card } from "@probo/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DeviceCurrentPostures_deviceFragment$key } from "#/__generated__/core/DeviceCurrentPostures_deviceFragment.graphql";
|
||||
|
||||
import { getPostureCheckLabel } from "../_lib/getPostureCheckLabel";
|
||||
|
||||
import { PostureValueBadge } from "./PostureValueBadge";
|
||||
|
||||
const deviceFragment = graphql`
|
||||
fragment DeviceCurrentPostures_deviceFragment on Device {
|
||||
latestPostures {
|
||||
id
|
||||
checkKey
|
||||
...PostureValueBadge_postureFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DeviceCurrentPosturesProps {
|
||||
deviceFragmentRef: DeviceCurrentPostures_deviceFragment$key;
|
||||
}
|
||||
|
||||
export function DeviceCurrentPostures({
|
||||
deviceFragmentRef,
|
||||
}: DeviceCurrentPosturesProps) {
|
||||
const { t } = useTranslation();
|
||||
const device = useFragment(deviceFragment, deviceFragmentRef);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">
|
||||
{t("devices.postures.currentTitle")}
|
||||
</h2>
|
||||
<Card className="space-y-4" padded>
|
||||
{device.latestPostures.length === 0
|
||||
? (
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{t("devices.postures.empty")}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{device.latestPostures.map(posture => (
|
||||
<div key={posture.id}>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{getPostureCheckLabel(t, posture.checkKey)}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
<PostureValueBadge postureFragmentRef={posture} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,8 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { dateFormat } from "@probo/i18n";
|
||||
import { dateFormat, dateTimeFormat } from "@probo/i18n";
|
||||
import { Badge, Card } from "@probo/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
@@ -55,64 +54,83 @@ export function DeviceDetailsCard(props: {
|
||||
return (
|
||||
<Card className="space-y-4" padded>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DetailField
|
||||
label={t("devices.fields.state")}
|
||||
value={
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.state")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
<Badge variant={stateVariant(device.state)}>{device.state}</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.owner")}
|
||||
value={device.owner?.fullName ?? t("devices.values.unassigned")}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.hardwareUuid")}
|
||||
value={displayValue(device.hardwareUuid, pendingLabel)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.serialNumber")}
|
||||
value={displayValue(device.serialNumber, pendingLabel)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.platform")}
|
||||
value={displayValue(device.platform, pendingLabel)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.osVersion")}
|
||||
value={displayValue(device.osVersion, pendingLabel)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.agentVersion")}
|
||||
value={displayValue(device.agentVersion, pendingLabel)}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.enrolledAt")}
|
||||
value={
|
||||
device.enrolledAt
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.owner")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{device.owner?.fullName ?? t("devices.values.unassigned")}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.hardwareUuid")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{displayValue(device.hardwareUuid, pendingLabel)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.serialNumber")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{displayValue(device.serialNumber, pendingLabel)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.platform")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{displayValue(device.platform, pendingLabel)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.osVersion")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{displayValue(device.osVersion, pendingLabel)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.agentVersion")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{displayValue(device.agentVersion, pendingLabel)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.enrolledAt")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{device.enrolledAt
|
||||
? dateFormat(i18n.language, device.enrolledAt)
|
||||
: pendingLabel
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label={t("devices.fields.lastSeen")}
|
||||
value={
|
||||
device.lastSeenAt
|
||||
? dateFormat(i18n.language, device.lastSeenAt)
|
||||
: t("devices.values.never")
|
||||
}
|
||||
/>
|
||||
: pendingLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{t("devices.fields.lastSeen")}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">
|
||||
{device.lastSeenAt
|
||||
? dateTimeFormat(i18n.language, device.lastSeenAt)
|
||||
: t("devices.values.never")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField(props: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-txt-tertiary font-semibold mb-1">
|
||||
{props.label}
|
||||
</div>
|
||||
<div className="text-sm text-txt-primary">{props.value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { dateFormat } from "@probo/i18n";
|
||||
import { dateTimeFormat } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Badge,
|
||||
@@ -52,10 +52,6 @@ const deviceRowFragment = graphql`
|
||||
id
|
||||
fullName
|
||||
}
|
||||
latestPostures {
|
||||
id
|
||||
status
|
||||
}
|
||||
...ReassignDeviceDialog_device
|
||||
}
|
||||
`;
|
||||
@@ -76,7 +72,6 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
||||
|
||||
const [confirmRevoke, isRevoking] = useRevokeDevice();
|
||||
|
||||
const summary = postureSummary(device.latestPostures);
|
||||
const isRevoked = device.state === "REVOKED";
|
||||
const hasActions = !isRevoked && (canRevoke || canAssignDevice);
|
||||
|
||||
@@ -97,18 +92,9 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
||||
<Td>{displayValue(device.osVersion, pendingLabel)}</Td>
|
||||
<Td>
|
||||
{device.lastSeenAt
|
||||
? dateFormat(i18n.language, device.lastSeenAt)
|
||||
? dateTimeFormat(i18n.language, device.lastSeenAt)
|
||||
: t("devices.values.never")}
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="text-txt-success">{summary.pass}</span>
|
||||
{" / "}
|
||||
<span className={summary.fail > 0 ? "text-txt-danger" : undefined}>
|
||||
{summary.fail}
|
||||
</span>
|
||||
{" / "}
|
||||
{summary.total}
|
||||
</Td>
|
||||
<Td noLink width={50} className="text-end">
|
||||
{hasActions && (
|
||||
<ActionDropdown>
|
||||
@@ -138,24 +124,3 @@ export function DeviceRow({ canAssignDevice, canRevoke, fKey }: DeviceRowProps)
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function postureSummary(
|
||||
postures: readonly { status: string }[],
|
||||
): { pass: number; fail: number; total: number } {
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
for (const p of postures) {
|
||||
switch (p.status) {
|
||||
case "PASS":
|
||||
pass += 1;
|
||||
break;
|
||||
case "FAIL":
|
||||
fail += 1;
|
||||
break;
|
||||
default:
|
||||
// UNKNOWN and NOT_APPLICABLE count toward total only.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { pass, fail, total: postures.length };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -18,38 +18,42 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { IconCircleInfo } from "@probo/ui";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Badge } from "@probo/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
export function PostureColumnHeader() {
|
||||
const { t } = useTranslation();
|
||||
import type { PostureValueBadge_postureFragment$key } from "#/__generated__/core/PostureValueBadge_postureFragment.graphql";
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("devices.postures.title")}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex text-txt-tertiary hover:text-txt-secondary"
|
||||
aria-label={t("devices.postures.summaryHelp")}
|
||||
>
|
||||
<IconCircleInfo size={14} />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="z-50 max-w-xs rounded-md border bg-level-0 p-3 text-xs font-normal text-txt-secondary shadow-md"
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
>
|
||||
<p>
|
||||
{t("devices.postures.summaryDescription")}
|
||||
</p>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</span>
|
||||
);
|
||||
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 <Badge variant={variant}>{label}</Badge>;
|
||||
}
|
||||
@@ -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, unknown>) => 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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<DevicePosturesPageQuery>;
|
||||
interface DeviceHistoryPageProps {
|
||||
queryRef: PreloadedQuery<DeviceHistoryPageQuery>;
|
||||
}
|
||||
|
||||
export function DevicePosturesPage({ queryRef }: DevicePosturesPageProps) {
|
||||
const { device } = usePreloadedQuery<DevicePosturesPageQuery>(
|
||||
devicePosturesPageQuery,
|
||||
export function DeviceHistoryPage({ queryRef }: DeviceHistoryPageProps) {
|
||||
const { device } = usePreloadedQuery<DeviceHistoryPageQuery>(
|
||||
deviceHistoryPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
if (device.__typename !== "Device") {
|
||||
throw new Error("invalid type for device node");
|
||||
}
|
||||
|
||||
return <DevicePostureList deviceFragmentRef={device} />;
|
||||
return <DevicePostureReportList fKey={device} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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>(
|
||||
devicePosturesPageQuery,
|
||||
const [queryRef, loadQuery] = useQueryLoader<DeviceHistoryPageQuery>(
|
||||
deviceHistoryPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -50,15 +50,15 @@ function DevicePosturesPageQueryLoader() {
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<DevicePosturesPage queryRef={queryRef} />
|
||||
<DeviceHistoryPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DevicePosturesPageLoader() {
|
||||
export default function DeviceHistoryPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<DevicePosturesPageQueryLoader />
|
||||
<DeviceHistoryPageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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 (
|
||||
<SortableTable
|
||||
{...reportsPagination}
|
||||
refetch={() => {
|
||||
reportsPagination.refetch({}, { fetchPolicy: "network-only" });
|
||||
}}
|
||||
pageSize={20}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t("devices.history.columns.time")}</Th>
|
||||
<Th className="text-txt-tertiary font-normal">
|
||||
{t("devices.history.columns.correlationId")}
|
||||
</Th>
|
||||
<Th>{t("devices.history.columns.checks")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{edges.length === 0
|
||||
? (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="text-center text-txt-secondary">
|
||||
{t("devices.history.empty")}
|
||||
</Td>
|
||||
</Tr>
|
||||
)
|
||||
: (
|
||||
edges.map(({ node: report }) => (
|
||||
<DevicePostureReportListItem
|
||||
key={report.id}
|
||||
fKey={report}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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 (
|
||||
<>
|
||||
<Tr
|
||||
className="cursor-pointer hover:bg-bg-hover"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<Td className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded
|
||||
? (
|
||||
<IconChevronDown size={16} className="text-txt-secondary" />
|
||||
)
|
||||
: (
|
||||
<IconChevronRight size={16} className="text-txt-secondary" />
|
||||
)}
|
||||
{dateTimeFormat(i18n.language, report.createdAt)}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="font-mono text-xs text-txt-tertiary">
|
||||
{report.id}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded text-txt-tertiary hover:bg-bg-hover hover:text-txt-secondary transition-colors cursor-pointer"
|
||||
aria-label={t("devices.history.actions.copyCorrelationId")}
|
||||
title={
|
||||
isCopied
|
||||
? t("devices.history.actions.correlationIdCopied")
|
||||
: t("devices.history.actions.copyCorrelationId")
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copy(report.id);
|
||||
}}
|
||||
>
|
||||
{isCopied
|
||||
? <IconCheckmark1 size={14} />
|
||||
: <IconSquareBehindSquare2 size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{t("devices.history.checkCount", {
|
||||
count: report.postures.length,
|
||||
})}
|
||||
</Td>
|
||||
</Tr>
|
||||
{isExpanded && (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="bg-bg-subtle">
|
||||
<div className="py-2 pl-6 grid grid-cols-2 gap-x-8 gap-y-2">
|
||||
{report.postures.map(posture => (
|
||||
<div key={posture.id} className="flex items-center gap-4 text-sm">
|
||||
<span className="text-txt-secondary min-w-40">
|
||||
{getPostureCheckLabel(t, posture.checkKey)}
|
||||
</span>
|
||||
<PostureValueBadge postureFragmentRef={posture} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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 (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{t("devices.postures.columns.check")}</Th>
|
||||
<Th>{t("devices.postures.columns.status")}</Th>
|
||||
<Th className="text-end">{t("devices.postures.columns.observedAt")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{device.latestPostures.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={3} className="text-center text-txt-secondary">
|
||||
{t("devices.postures.empty")}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{device.latestPostures.map(posture => (
|
||||
<DevicePostureListItem key={posture.id} postureKey={posture} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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 (
|
||||
<Tr>
|
||||
<Td>{getPostureCheckLabel(t, posture.checkKey)}</Td>
|
||||
<Td>
|
||||
<Badge variant={statusVariant(posture.status)}>
|
||||
{getPostureStatusLabel(t, posture.status)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="text-end whitespace-nowrap">
|
||||
{dateTimeFormat(i18n.language, posture.observedAt)}
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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<string, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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"),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
236
contrib/seed.sh
236
contrib/seed.sh
@@ -808,18 +808,241 @@ agent_heartbeat() {
|
||||
fi
|
||||
}
|
||||
|
||||
# agent_postures <api_key> <CHECK_KEY:STATUS>...
|
||||
# posture_evidence <platform> <check_key> <status> <os_version>
|
||||
# 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:"<dict><key>policyCategoryPasswordContent</key><array/></dict>"}' ;;
|
||||
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 <api_key> <platform> <os_version> <CHECK_KEY:STATUS>...
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
145
pkg/coredata/device_posture_report.go
Normal file
145
pkg/coredata/device_posture_report.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
84
pkg/coredata/device_posture_report_order_field.go
Normal file
84
pkg/coredata/device_posture_report_order_field.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
322
pkg/coredata/device_posture_report_test.go
Normal file
322
pkg/coredata/device_posture_report_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
409
pkg/coredata/device_posture_value.go
Normal file
409
pkg/coredata/device_posture_value.go
Normal file
@@ -0,0 +1,409 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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] + "…"
|
||||
}
|
||||
584
pkg/coredata/device_posture_value_checks.go
Normal file
584
pkg/coredata/device_posture_value_checks.go
Normal file
@@ -0,0 +1,584 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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: <state>" 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)
|
||||
}
|
||||
121
pkg/coredata/device_posture_value_kind.go
Normal file
121
pkg/coredata/device_posture_value_kind.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
1070
pkg/coredata/device_posture_value_test.go
Normal file
1070
pkg/coredata/device_posture_value_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
48
pkg/coredata/migrations/20260728T174416Z.sql
Normal file
48
pkg/coredata/migrations/20260728T174416Z.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
--
|
||||
-- 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);
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user