diff --git a/apps/api/src/policies/dto/create-policy.dto.spec.ts b/apps/api/src/policies/dto/create-policy.dto.spec.ts new file mode 100644 index 0000000000..588c0f51cd --- /dev/null +++ b/apps/api/src/policies/dto/create-policy.dto.spec.ts @@ -0,0 +1,73 @@ +import { ValidationPipe } from '@nestjs/common'; +import { CreatePolicyDto } from './create-policy.dto'; +import { UpdatePolicyDto } from './update-policy.dto'; + +/** + * Regression test for the MCP/public-API policy content serialization bug. + * + * The global ValidationPipe in main.ts runs with `transform: true` and + * `transformOptions: { enableImplicitConversion: true }`. class-transformer + * then coerces each TipTap node object toward the reflected `Array` design-type + * of `content: unknown[]`, turning `[{...}, {...}]` into `[[], []]` — silently + * blanking the policy body on create/update. These tests exercise the exact + * pipe configuration and assert the structured content survives intact. + */ +const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, +}); + +const TIPTAP_NODES = [ + { + type: 'heading', + attrs: { level: 2, textAlign: null }, + content: [{ type: 'text', text: 'Purpose' }], + }, + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { + type: 'text', + text: 'Verify workforce integrity and grant the right access.', + marks: [{ type: 'bold' }], + }, + ], + }, +]; + +describe('Policy DTO content serialization (ValidationPipe)', () => { + it('preserves structured TipTap content on CreatePolicyDto', async () => { + const result = await pipe.transform( + { name: 'Access Control Policy', content: TIPTAP_NODES }, + { type: 'body', metatype: CreatePolicyDto }, + ); + + expect(result.content).toEqual(TIPTAP_NODES); + // Guard against the exact regression: nodes must not collapse to `[]`. + expect(result.content[0]).toMatchObject({ type: 'heading' }); + expect(result.content).not.toEqual([[], []]); + }); + + it('preserves structured TipTap content on UpdatePolicyDto', async () => { + const result = await pipe.transform( + { content: TIPTAP_NODES }, + { type: 'body', metatype: UpdatePolicyDto }, + ); + + expect(result.content).toEqual(TIPTAP_NODES); + expect(result.content[1]).toMatchObject({ type: 'paragraph' }); + expect(result.content).not.toEqual([[], []]); + }); + + it('leaves content untouched when omitted on update', async () => { + const result = await pipe.transform( + { name: 'Renamed only' }, + { type: 'body', metatype: UpdatePolicyDto }, + ); + + expect(result.content).toBeUndefined(); + }); +}); diff --git a/apps/api/src/policies/dto/create-policy.dto.ts b/apps/api/src/policies/dto/create-policy.dto.ts index 2a621b547b..4c5fc27434 100644 --- a/apps/api/src/policies/dto/create-policy.dto.ts +++ b/apps/api/src/policies/dto/create-policy.dto.ts @@ -90,7 +90,12 @@ export class CreatePolicyDto { items: { type: 'object', additionalProperties: true }, }) @IsArray() - @Transform(({ value }) => value) + // Return the raw source value. Under the global ValidationPipe's implicit + // conversion, class-transformer coerces each TipTap node toward the reflected + // Array design-type of `content`, mangling `[{...}, {...}]` into `[[], []]`. + // The transform runs after that coercion, so `value` is already mangled — + // `obj.content` is the untouched original. Do not revert this to `value`. + @Transform(({ obj }) => obj.content) content: unknown[]; @ApiProperty({ diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index 08b133a7ab..71bcfaf6a4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -248,16 +248,19 @@ describe('DeviceAgentDevicesList — integration-imported devices', () => { expect(screen.getByTitle('Offline')).toBeInTheDocument(); }); - it('shows the SOURCE-reported verdict and named checks when the provider reports them', () => { + it('shows Yes only when all four canonical checks pass (extras are display-only)', () => { render( { ]} />, ); - // Verdict shown instead of "Not tracked", with provider attribution. expect(screen.getByText('Yes')).toBeInTheDocument(); - expect(screen.queryByText('Not tracked')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /Where does this compliance status come from\?/i }), - ).toBeInTheDocument(); - // Provider-named check badges, pass and fail. + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); + // Provider-named check badges, incl. the informational extra. expect(screen.getByText('Disk Encryption')).toBeInTheDocument(); expect(screen.getByText('Firewall')).toBeInTheDocument(); }); - it('shows "No" when the source reports non-compliant', () => { + it('shows "No" when a canonical check fails, even if the vendor claims compliant (showcase bug)', () => { render( , ); expect(screen.getByText('No')).toBeInTheDocument(); - expect(screen.queryByText('Not tracked')).not.toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); + }); + + it('shows "Unverified (n/4)" when only some canonical checks are reported and none fail', () => { + render( + , + ); + expect(screen.getByText('Unverified (2/4)')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Why is compliance unverified\?/i }), + ).toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); + expect(screen.queryByText('No')).not.toBeInTheDocument(); }); - it('keeps "Not tracked" when the source reports checks but no verdict', () => { + it('keeps "Not tracked" when the source reports no canonical checks at all', () => { render( , ); - // Checks render, but with no overall verdict the compliance column stays honest. expect(screen.getByText('OS up to date')).toBeInTheDocument(); expect(screen.getByText('Not tracked')).toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); }); it('renders a source filter only when more than one source is present', () => { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx index 2e3069e764..ccc60736a0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx @@ -220,11 +220,11 @@ describe('DeviceComplianceChart', () => { expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); }); - it('does not show a Not Tracked legend entry when every device has a verdict', () => { + it('does not show an Unverified legend entry when every device has a verdict', () => { render( , ); - expect(screen.queryByText(/Not Tracked/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); }); }); @@ -244,32 +244,49 @@ function makeIntegrationDevice( } describe('DeviceComplianceChart — integration-imported devices', () => { - it('counts an imported device with a source verdict of compliant as Compliant', () => { + it('counts an imported device with all four canonical checks passing as Compliant', () => { render( , ); expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); - expect(screen.queryByText(/Not Tracked/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); }); - it('counts an imported device with a source verdict of non-compliant as Non-Compliant', () => { + it('counts an imported device with a failed canonical check as Non-Compliant (vendor verdict ignored)', () => { render( , ); expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); }); - it('puts an imported device with no source verdict into a Not Tracked segment', () => { + it('puts an imported device with partial/no canonical checks into an Unverified segment', () => { render( { ); expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); expect(screen.getByText('Non-Compliant (0)')).toBeInTheDocument(); - expect(screen.getByText('Not Tracked (1)')).toBeInTheDocument(); + expect(screen.getByText('Unverified (1)')).toBeInTheDocument(); }); it('renders the chart (not the empty state) for an org with ONLY imported devices', () => { render( , ); expect(screen.queryByText(/No device data available/)).not.toBeInTheDocument(); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx index 0345049698..8340d24fd2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -10,6 +10,7 @@ import { import * as React from 'react'; import { Cell, Label, Pie, PieChart } from 'recharts'; import type { DeviceWithChecks, Host } from '../types'; +import { computeSourceComplianceVerdict } from '../lib/device-source'; interface DeviceComplianceChartProps { fleetDevices: Host[]; @@ -23,14 +24,15 @@ interface DeviceComplianceChartProps { const CHART_COLORS = { compliant: 'var(--primary)', // brand green nonCompliant: 'var(--destructive)', // red - notTracked: 'var(--muted-foreground)', // gray — no compliance data available + unverified: 'var(--muted-foreground)', // gray — canonical checks not (fully) reported }; export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComplianceChartProps) { // ALL devices count — the chart total must match the table so the page never - // looks self-contradictory. Imported devices use the SOURCE's verdict when - // it reports one; imported devices with no verdict land in a gray - // "Not tracked" segment rather than being fabricated into a verdict. + // looks self-contradictory. Imported devices are judged by CompAI's OWN + // standard (the four canonical checks, computed from source-reported data); + // partially/un-reported devices land in a gray "Unverified" segment rather + // than being fabricated into a verdict. const devices = [...(agentDevices ?? []), ...(fleetDevices ?? [])]; const { pieDisplayData, legendDisplayData } = React.useMemo(() => { @@ -39,15 +41,18 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp } let compliantCount = 0; let nonCompliantCount = 0; - let notTrackedCount = 0; + let unverifiedCount = 0; for (const device of agentDevices ?? []) { if (device.source === 'integration') { - // Source-reported verdict (e.g. Intune complianceState), if any. - const verdict = device.sourceCompliance?.isCompliant; - if (verdict === true) compliantCount++; - else if (verdict === false) nonCompliantCount++; - else notTrackedCount++; + // CompAI's verdict computed from the canonical source-reported checks + // (vendor's own verdict is informational-only and never counted). + const verdict = computeSourceComplianceVerdict(device); + if (verdict?.kind === 'compliant') compliantCount++; + else if (verdict?.kind === 'non_compliant') nonCompliantCount++; + // Both partial ('unverified') and zero-data ('not_tracked') devices + // land in the gray segment: neither has a defensible verdict. + else unverifiedCount++; continue; } // Device-agent devices. Stale devices (no check-in for >= 7 days) @@ -81,17 +86,17 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp fill: CHART_COLORS.nonCompliant, }, { - name: 'Not Tracked', - value: notTrackedCount, - fill: CHART_COLORS.notTracked, + name: 'Unverified', + value: unverifiedCount, + fill: CHART_COLORS.unverified, }, ]; return { pieDisplayData: allItems.filter((item) => item.value > 0), - // "Not Tracked" only appears in the legend when it exists — orgs with no - // verdict-less imported devices keep the familiar two-entry legend. + // "Unverified" only appears in the legend when it exists — orgs with no + // partially-verified imported devices keep the familiar two-entry legend. legendDisplayData: allItems.filter( - (item) => item.name !== 'Not Tracked' || item.value > 0, + (item) => item.name !== 'Unverified' || item.value > 0, ), }; }, [agentDevices, fleetDevices]); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index a52874cde2..e8c51ebf67 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -19,38 +19,46 @@ import { ArrowLeft, Information } from '@trycompai/design-system/icons'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import type { DeviceWithChecks } from '../types'; import { + CANONICAL_DEVICE_CHECKS, CHECK_FIELDS, PLATFORM_LABELS, + computeSourceComplianceVerdict, isDeviceOnline, sourceChecks, - sourceReportedTooltipCopy, sourceVerdict, staleLabel, staleTooltipCopy, + unverifiedTooltipCopy, } from '../lib/device-source'; import { NotTrackedBadge } from './DeviceListCells'; import { RevokeAgentAccessDialog } from './RevokeAgentAccessDialog'; function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { if (device.source === 'integration') { - // Show the SOURCE's own verdict when it reports one (attributed via the - // same tooltip pattern as the stale/not-tracked badges, so the provenance - // is reachable by keyboard/touch); otherwise untracked, as before. - const verdict = sourceVerdict(device); - if (verdict === undefined) { + // CompAI's verdict, computed from the source-reported CANONICAL checks — + // the same standard as the Comp agent. The vendor's own overall verdict is + // informational only (shown in the info grid below). + const verdict = computeSourceComplianceVerdict(device); + if (verdict === null || verdict.kind === 'not_tracked') { return ; } + if (verdict.kind === 'non_compliant') { + return Non-Compliant; + } + if (verdict.kind === 'compliant') { + return Compliant; + } return (
- - {verdict ? 'Compliant' : 'Non-Compliant'} + + Unverified ({verdict.reported}/{CANONICAL_DEVICE_CHECKS.length}) - {sourceReportedTooltipCopy(device)} + {unverifiedTooltipCopy(device, verdict)} @@ -214,6 +222,20 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { {new Date(device.installedAt).toLocaleDateString()}
+ {/* The vendor's own overall verdict — informational only. It + reflects the customer's MDM policy configuration; CompAI's + Compliant badge above is computed from the canonical checks. */} + {device.source === 'integration' && sourceVerdict(device) !== undefined && ( +
+ + {device.integrationProvider?.name ?? 'Provider'} verdict + + + {sourceVerdict(device) ? 'Compliant' : 'Non-Compliant'} (per + its own policies) + +
+ )} @@ -228,42 +250,83 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { - {/* Imported device with SOURCE-reported checks: show those (provider - vocabulary) instead of CompAI's fixed agent checks. */} - {device.source === 'integration' && sourceChecks(device).length > 0 && - sourceChecks(device).map((check) => ( - - - - {check.label} - - - - - Reported by {device.integrationProvider?.name ?? 'the integration'} - - - - - {check.passed ? 'Pass' : 'Fail'} - - - - - — - - - - ))} - {!(device.source === 'integration' && sourceChecks(device).length > 0) && + {/* Imported device: always render CompAI's FOUR canonical checks — + filled from source data where reported, honest "Not reported" + otherwise — then any extra provider-specific checks below. */} + {device.source === 'integration' && + (() => { + const provider = device.integrationProvider?.name ?? 'the integration'; + const bySourceId = new Map(sourceChecks(device).map((c) => [c.id, c])); + const canonicalIds = new Set( + CANONICAL_DEVICE_CHECKS.map((c) => c.id), + ); + const extras = sourceChecks(device).filter((c) => !canonicalIds.has(c.id)); + return [ + ...CANONICAL_DEVICE_CHECKS.map(({ id, label }) => { + const reported = bySourceId.get(id); + return ( + + + + {label} + + + + + {reported + ? `Reported by ${provider}` + : `Not reported by ${provider} — install the CompAI agent to verify`} + + + + {reported ? ( + + {reported.passed ? 'Pass' : 'Fail'} + + ) : ( + Unverified + )} + + + + — + + + + ); + }), + ...extras.map((check) => ( + + + + {check.label} + + + + + Reported by {provider} (informational — not part of the + compliance verdict) + + + + + {check.passed ? 'Pass' : 'Fail'} + + + + + — + + + + )), + ]; + })()} + {device.source !== 'integration' && CHECK_FIELDS.map(({ key, dbKey, label }) => { - const isIntegration = device.source === 'integration'; - const isFleetUnsupported = + const isUntracked = device.source === 'fleet' && key !== 'diskEncryptionEnabled'; - const isUntracked = isIntegration || isFleetUnsupported; - const untrackedCopy = isIntegration - ? 'Not collected for imported devices' - : 'Not tracked by Fleet'; + const untrackedCopy = 'Not tracked by Fleet'; const isStale = device.complianceStatus === 'stale'; const passed = device[key]; const details = device.checkDetails?.[dbKey]; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx index 9e744dbcac..401bfb02ba 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx @@ -24,8 +24,10 @@ import { import Link from 'next/link'; import type { DeviceWithChecks } from '../types'; import { + CANONICAL_DEVICE_CHECKS, CHECK_FIELDS, PLATFORM_LABELS, + computeSourceComplianceVerdict, formatTimeAgo, isComplianceTracked, isDeviceOnline, @@ -33,9 +35,9 @@ import { sourceChecks, sourceLabel, sourceReportedTooltipCopy, - sourceVerdict, staleLabel, staleTooltipCopy, + unverifiedTooltipCopy, } from '../lib/device-source'; function InfoTooltip({ label, copy }: { label: string; copy: string }) { @@ -111,23 +113,30 @@ export function NotTrackedBadge({ device }: { device: DeviceWithChecks }) { } export function CompliantBadge({ device }: { device: DeviceWithChecks }) { - // Integration-imported devices: CompAI never ran security checks on them, so - // a red "No" from OUR checks would be a false negative. But when the SOURCE - // reports its own verdict (e.g. Intune complianceState), show that — clearly - // attributed to the provider. No verdict → untracked, as before. + // Integration-imported devices are judged by CompAI's OWN standard — the + // same four canonical checks the Comp agent measures — computed from the + // source-reported check data. The vendor's own verdict (e.g. Intune + // complianceState) is informational-only in the details panel: it reflects + // the customer's MDM policy configuration, not the framework standard. if (!isComplianceTracked(device)) { - const verdict = sourceVerdict(device); - if (verdict === undefined) { + const verdict = computeSourceComplianceVerdict(device); + if (verdict === null || verdict.kind === 'not_tracked') { return ; } + if (verdict.kind === 'non_compliant') { + return No; + } + if (verdict.kind === 'compliant') { + return Yes; + } return (
- - {verdict ? 'Yes' : 'No'} + + Unverified ({verdict.reported}/{CANONICAL_DEVICE_CHECKS.length})
); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts index 4cbc938f55..af81263f0d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts @@ -34,14 +34,81 @@ export function isComplianceTracked(device: DeviceWithChecks): boolean { } /** - * The SOURCE integration's own compliance verdict for an imported device, or - * undefined when the provider doesn't report one (renders "Not tracked"). + * The SOURCE integration's own overall verdict (e.g. Intune complianceState). + * INFORMATIONAL ONLY — shown in the details panel, never used for CompAI's + * Compliant column/chart: a vendor verdict reflects the customer's MDM policy + * configuration, not CompAI's framework standard (a policy-less Intune tenant + * calls everything "compliant"). */ export function sourceVerdict(device: DeviceWithChecks): boolean | undefined { if (device.source !== 'integration') return undefined; return device.sourceCompliance?.isCompliant; } +/** + * CompAI's framework standard for device compliance — the same four checks + * the Comp agent measures. A device (from ANY source) is compliant only when + * all four pass. + */ +export const CANONICAL_DEVICE_CHECKS = [ + { id: 'disk_encryption', label: 'Disk Encryption' }, + { id: 'antivirus', label: 'Antivirus' }, + { id: 'password_policy', label: 'Password Policy' }, + { id: 'screen_lock', label: 'Screen Lock' }, +] as const; + +export type SourceComplianceVerdict = + | { kind: 'compliant' } + | { kind: 'non_compliant' } + /** SOME canonical checks reported (>= 1), none failed. */ + | { kind: 'unverified'; reported: number; missing: string[] } + /** ZERO canonical checks reported — the source gives us nothing to judge. */ + | { kind: 'not_tracked' }; + +/** + * Computes CompAI's compliance verdict for an imported device from the + * source-reported CANONICAL checks (extra provider-specific checks are + * display-only and never affect the verdict): + * - any canonical check failed -> non_compliant (one failure is enough) + * - all four reported & passed -> compliant + * - some reported, none failed -> unverified (n of 4), listing what's missing + * - zero reported -> not_tracked (its own kind, so consumers + * can't accidentally lump it into unverified) + */ +export function computeSourceComplianceVerdict( + device: DeviceWithChecks, +): SourceComplianceVerdict | null { + if (device.source !== 'integration') return null; + const canonicalIds = new Set(CANONICAL_DEVICE_CHECKS.map((c) => c.id)); + const canonical = sourceChecks(device).filter((c) => canonicalIds.has(c.id)); + if (canonical.some((c) => !c.passed)) { + return { kind: 'non_compliant' }; + } + const reportedIds = new Set(canonical.map((c) => c.id)); + if (reportedIds.size === CANONICAL_DEVICE_CHECKS.length) { + return { kind: 'compliant' }; + } + if (reportedIds.size === 0) { + return { kind: 'not_tracked' }; + } + return { + kind: 'unverified', + reported: reportedIds.size, + missing: CANONICAL_DEVICE_CHECKS.filter((c) => !reportedIds.has(c.id)).map( + (c) => c.label, + ), + }; +} + +/** Tooltip copy for the "Unverified (n/4)" badge on imported devices. */ +export function unverifiedTooltipCopy( + device: DeviceWithChecks, + verdict: Extract, +): string { + const provider = device.integrationProvider?.name ?? 'The integration'; + return `${provider} reports ${verdict.reported} of the ${CANONICAL_DEVICE_CHECKS.length} security checks CompAI requires for compliance (missing: ${verdict.missing.join(', ')}). Install the CompAI agent on this device for full verification.`; +} + /** * The SOURCE integration's own named checks for an imported device (empty when * the provider reports none). Provider vocabulary — rendered as-is. diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts index d0432a606f..2768a9b4cc 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts @@ -94,7 +94,7 @@ describe('buildDevicesCsv', () => { expect(cells.slice(9, 13)).toEqual(['no', 'no', 'yes', 'yes']); // the four checks }); - it('exports imported devices as not_tracked / n/a with the provider as source', () => { + it('exports imported devices as not_tracked / unverified with the provider as source', () => { const csv = buildDevicesCsv([ makeDevice({ source: 'integration', @@ -111,7 +111,8 @@ describe('buildDevicesCsv', () => { ]); const cells = stripBom(csv).slice(0, -2).split('\r\n')[1].split(','); expect(cells[8]).toBe('not_tracked'); // status - expect(cells.slice(9, 13)).toEqual(['n/a', 'n/a', 'n/a', 'n/a']); + // Canonical checks unreported by the source export as 'unverified'. + expect(cells.slice(9, 13)).toEqual(['unverified', 'unverified', 'unverified', 'unverified']); expect(cells[13]).toBe('Kandji'); // source }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts index 81ab47c0e5..7670499a0e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -1,5 +1,11 @@ import type { DeviceWithChecks } from '../types'; -import { isComplianceTracked, sourceLabel, sourceVerdict } from './device-source'; +import { + CANONICAL_DEVICE_CHECKS, + computeSourceComplianceVerdict, + isComplianceTracked, + sourceChecks, + sourceLabel, +} from './device-source'; export const DEVICES_CSV_HEADER = [ 'Device Name', @@ -38,19 +44,24 @@ function yesNo(value: boolean): 'yes' | 'no' { export function buildDevicesCsv(devices: DeviceWithChecks[]): string { const rows = devices.map((d) => { - // Integration-imported devices are inventory-only for OUR checks; agent + - // Fleet carry measured compliance. When the source integration reports its - // own verdict, export it with explicit attribution. + // Imported devices are judged by CompAI's OWN standard — the same four + // canonical checks the agent measures — computed from the data the source + // integration reports. Unreported canonical checks export as 'unverified'. const tracked = isComplianceTracked(d); - const verdict = sourceVerdict(d); + const verdict = computeSourceComplianceVerdict(d); const status = tracked ? d.complianceStatus - : verdict === undefined + : verdict === null || verdict.kind === 'not_tracked' ? 'not_tracked' - : verdict - ? 'compliant (source-reported)' - : 'non_compliant (source-reported)'; - const check = (value: boolean) => (tracked ? yesNo(value) : 'n/a'); + : verdict.kind === 'unverified' + ? `unverified (${verdict.reported}/${CANONICAL_DEVICE_CHECKS.length} checks reported)` + : verdict.kind; + const bySourceId = new Map(sourceChecks(d).map((c) => [c.id, c.passed])); + const check = (agentValue: boolean, canonicalId: string) => { + if (tracked) return yesNo(agentValue); + const reported = bySourceId.get(canonicalId); + return reported === undefined ? 'unverified' : yesNo(reported); + }; return [ escapeCell(d.name), escapeCell(d.user.name), @@ -61,10 +72,10 @@ export function buildDevicesCsv(devices: DeviceWithChecks[]): string { escapeCell(d.lastCheckIn ?? ''), escapeCell(d.daysSinceLastCheckIn ?? ''), escapeCell(status), - escapeCell(check(d.diskEncryptionEnabled)), - escapeCell(check(d.antivirusEnabled)), - escapeCell(check(d.passwordPolicySet)), - escapeCell(check(d.screenLockEnabled)), + escapeCell(check(d.diskEncryptionEnabled, 'disk_encryption')), + escapeCell(check(d.antivirusEnabled, 'antivirus')), + escapeCell(check(d.passwordPolicySet, 'password_policy')), + escapeCell(check(d.screenLockEnabled, 'screen_lock')), escapeCell(sourceLabel(d)), ].join(','); });