diff --git a/packages/integration-platform/src/manifests/msp360-backup/__tests__/checks.test.ts b/packages/integration-platform/src/manifests/msp360-backup/__tests__/checks.test.ts new file mode 100644 index 0000000000..ea632d0346 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/__tests__/checks.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'bun:test'; +import { appAvailabilityCheck } from '../checks/app-availability'; +import { backupLogsCheck } from '../checks/backup-logs'; +import { backupRestorationTestCheck } from '../checks/backup-restoration-test'; +import { employeeAccessCheck } from '../checks/employee-access'; +import { makeBackupCtx } from './test-utils'; + +const TOKEN = { access_token: 'backup-jwt' }; + +function router(path: string, method: string | undefined, handlers: Record) { + if (path.includes('/Provider/Login') && method === 'POST') { + return TOKEN; + } + for (const [key, value] of Object.entries(handlers)) { + if (path.includes(key)) { + if (value instanceof Error) { + throw value; + } + return value; + } + } + throw new Error(`Unexpected ${method} ${path}`); +} + +describe('msp360-backup appAvailabilityCheck', () => { + it('passes after Provider Login and Administrators ping', async () => { + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Administrators': [{ Email: 'ops@example.com' }], + }), + }); + await appAvailabilityCheck.run(ctx); + expect(failed).toHaveLength(0); + expect(passed.some((r) => r.resourceId === 'msp360-backup')).toBe(true); + }); + + it('fails when credentials are missing', async () => { + const { ctx, failed } = makeBackupCtx({ + credentials: {}, + fetchImpl: async () => { + throw new Error('should not call API'); + }, + }); + await appAvailabilityCheck.run(ctx); + expect(failed.length).toBeGreaterThan(0); + }); + + it('fails when the Administrators ping throws after login', async () => { + const { ctx, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Administrators': new Error('403 forbidden'), + }), + }); + await appAvailabilityCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'msp360-backup')).toBe(true); + }); +}); + +describe('msp360-backup employeeAccessCheck', () => { + it('emits one user row per administrator keyed by lowercased email', async () => { + const { ctx, passed, failed, calls } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Administrators': [ + { Email: 'Ops@Example.com', FirstName: 'Ops', LastName: 'Person', Enabled: true }, + { Email: 'old@example.com', FirstName: 'Old', LastName: 'Admin', Enabled: false }, + ], + }), + }); + await employeeAccessCheck.run(ctx); + expect(failed).toHaveLength(0); + const users = passed.filter((r) => r.resourceType === 'user'); + expect(users).toHaveLength(2); + expect(users.map((r) => r.resourceId).sort()).toEqual(['old@example.com', 'ops@example.com']); + expect(calls.some((c) => c.path.includes('/Users'))).toBe(false); + }); + + it('fails when the administrator list is empty', async () => { + const { ctx, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Administrators': [], + }), + }); + await employeeAccessCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'msp360-backup-admins')).toBe(true); + }); +}); + +describe('msp360-backup backupLogsCheck', () => { + it('passes recent successful backups, fails error jobs, and treats stale success as paused', async () => { + const now = new Date().toISOString(); + const stale = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(); + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [ + { + PlanName: 'Files', + ComputerName: 'ok-host', + PlanType: 3, + Status: 0, + LastStart: now, + PlanId: 'p-ok', + }, + { + PlanName: 'Image', + ComputerName: 'bad-host', + PlanType: 1, + Status: 2, + LastStart: now, + PlanId: 'p-fail', + ErrorMessage: 'disk full', + }, + { + PlanName: 'Old', + ComputerName: 'stale-host', + PlanType: 1, + Status: 0, + LastStart: stale, + PlanId: 'p-stale', + }, + ], + }), + }); + await backupLogsCheck.run(ctx); + expect(passed.some((r) => r.resourceId === 'p-ok')).toBe(true); + expect(failed.some((r) => r.resourceId === 'p-fail')).toBe(true); + expect(failed.some((r) => r.resourceId === 'p-stale')).toBe(false); + expect(passed.some((r) => r.resourceId === 'p-stale')).toBe(true); + }); + + it('fails Running/Unknown instead of treating them as paused', async () => { + const now = new Date().toISOString(); + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [ + { + PlanName: 'Running job', + ComputerName: 'host', + PlanType: 1, + Status: 3, + LastStart: now, + PlanId: 'p-run', + }, + { + PlanName: 'Unknown job', + ComputerName: 'host', + PlanType: 1, + Status: 4, + LastStart: now, + PlanId: 'p-unk', + }, + ], + }), + }); + await backupLogsCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'p-run')).toBe(true); + expect(failed.some((r) => r.resourceId === 'p-unk')).toBe(true); + expect(passed.some((r) => r.resourceId === 'p-run' || r.resourceId === 'p-unk')).toBe(false); + }); + + it('fails when monitoring has no in-scope backup plans', async () => { + const { ctx, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [{ PlanName: 'Restore files', PlanType: 4, Status: 0, LastStart: new Date().toISOString() }], + }), + }); + await backupLogsCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'msp360-backup-plans')).toBe(true); + }); + + it('fails collection on a malformed monitoring payload', async () => { + const { ctx, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': { unexpected: true }, + }), + }); + await backupLogsCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'msp360-monitoring')).toBe(true); + }); +}); + +describe('msp360-backup backupRestorationTestCheck', () => { + it('passes when a restore-family plan succeeded in the last 90 days', async () => { + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [ + { + PlanName: 'Restore files', + PlanType: 4, + Status: 0, + LastStart: new Date().toISOString(), + PlanId: 'restore-1', + }, + ], + }), + }); + await backupRestorationTestCheck.run(ctx); + expect(failed).toHaveLength(0); + expect(passed.some((r) => r.resourceId === 'restore-1')).toBe(true); + }); + + it('passes as not in scope when no successful restore exists in the window', async () => { + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [ + { + PlanName: 'Files backup', + PlanType: 3, + Status: 0, + LastStart: new Date().toISOString(), + PlanId: 'backup-only', + }, + ], + }), + }); + await backupRestorationTestCheck.run(ctx); + expect(failed).toHaveLength(0); + expect(passed.some((r) => r.resourceId === 'msp360-restore-not-in-scope')).toBe(true); + }); + + it('fails a restore-family job that ran in the window with an error status', async () => { + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [ + { + PlanName: 'SQLResore', + PlanType: 'SQLResore', + Status: 2, + LastStart: new Date().toISOString(), + PlanId: 'restore-fail', + ErrorMessage: 'disk missing', + }, + ], + }), + }); + await backupRestorationTestCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'restore-fail')).toBe(true); + expect(passed.some((r) => r.resourceId === 'msp360-restore-not-in-scope')).toBe(false); + }); + + it('fails an in-window restore with a missing or unrecognized status instead of N/A', async () => { + const { ctx, passed, failed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': [ + { + PlanName: 'Restore files', + PlanType: 4, + LastStart: new Date().toISOString(), + PlanId: 'restore-missing-status', + }, + { + PlanName: 'Restore verify', + PlanType: 4, + Status: 'bogus', + LastStart: new Date().toISOString(), + PlanId: 'restore-unrecognized-status', + }, + ], + }), + }); + await backupRestorationTestCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'restore-missing-status')).toBe(true); + expect(failed.some((r) => r.resourceId === 'restore-unrecognized-status')).toBe(true); + expect(passed.some((r) => r.resourceId === 'msp360-restore-not-in-scope')).toBe(false); + }); + + it('fails collection on a malformed monitoring payload instead of N/A', async () => { + const { ctx, failed, passed } = makeBackupCtx({ + fetchImpl: async (path, init) => + router(path, init?.method, { + '/Monitoring': { foo: 1 }, + }), + }); + await backupRestorationTestCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'msp360-restore-monitoring')).toBe(true); + expect(passed.some((r) => r.resourceId === 'msp360-restore-not-in-scope')).toBe(false); + }); +}); diff --git a/packages/integration-platform/src/manifests/msp360-backup/__tests__/monitoring.test.ts b/packages/integration-platform/src/manifests/msp360-backup/__tests__/monitoring.test.ts new file mode 100644 index 0000000000..88f7dc618b --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/__tests__/monitoring.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'bun:test'; +import { + isBackupPlan, + isIncompleteStatus, + isRestorePlan, + parseMonitoringPayload, + rowId, +} from '../monitoring'; + +describe('msp360-backup monitoring helpers', () => { + it('treats a known list envelope as ok and other objects as malformed', () => { + expect(parseMonitoringPayload([]).ok).toBe(true); + expect(parseMonitoringPayload({ data: [] }).ok).toBe(true); + expect(parseMonitoringPayload({ items: [{ PlanName: 'x' }] }).ok).toBe(true); + expect(parseMonitoringPayload({ unexpected: true }).ok).toBe(false); + expect(parseMonitoringPayload(null).ok).toBe(false); + expect(parseMonitoringPayload('nope').ok).toBe(false); + }); + + it('recognizes SQLResore spelling and restore numeric types before name fallback', () => { + expect(isRestorePlan({ PlanType: 8, PlanName: 'SQL' })).toBe(true); + expect(isRestorePlan({ PlanType: 'SQLResore' })).toBe(true); + expect(isRestorePlan({ PlanType: 4, PlanName: 'Files' })).toBe(true); + expect(isRestorePlan({ PlanType: 3, PlanName: 'Restore-looking backup' })).toBe(false); + expect(isBackupPlan({ PlanType: 3, PlanName: 'Restore-looking backup' })).toBe(true); + }); + + it('treats Running and Unknown as incomplete, not failed', () => { + expect(isIncompleteStatus(3)).toBe(true); + expect(isIncompleteStatus(4)).toBe(true); + expect(isIncompleteStatus('running')).toBe(true); + expect(isIncompleteStatus(0)).toBe(false); + expect(isIncompleteStatus(2)).toBe(false); + }); + + it('includes the loop index when PlanId is missing so rows do not collide', () => { + expect(rowId({ ComputerName: 'a', PlanName: 'Files' }, 0)).toBe('a:Files:0'); + expect(rowId({ ComputerName: 'a', PlanName: 'Files' }, 1)).toBe('a:Files:1'); + expect(rowId({ PlanId: 'p1', ComputerName: 'a' }, 9)).toBe('p1'); + }); +}); diff --git a/packages/integration-platform/src/manifests/msp360-backup/__tests__/test-utils.ts b/packages/integration-platform/src/manifests/msp360-backup/__tests__/test-utils.ts new file mode 100644 index 0000000000..3efad677e1 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/__tests__/test-utils.ts @@ -0,0 +1,54 @@ +import type { CheckContext, CheckFindingResult, CheckPassingResult } from '../../../types'; + +export interface MockFetchCall { + method: 'GET' | 'POST'; + path: string; +} + +export function makeBackupCtx(options: { + credentials?: Record; + fetchImpl: (path: string, init?: { method?: string }) => Promise; +}): { + ctx: CheckContext; + passed: CheckPassingResult[]; + failed: CheckFindingResult[]; + calls: MockFetchCall[]; +} { + const passed: CheckPassingResult[] = []; + const failed: CheckFindingResult[] = []; + const calls: MockFetchCall[] = []; + + const ctx: CheckContext = { + accessToken: '', + credentials: options.credentials ?? { + username: 'api-user', + password: 'secret', + baseUrl: 'https://api.mspbackups.com', + }, + variables: {}, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: {}, + log: () => {}, + warn: () => {}, + error: () => {}, + pass: (result) => { + passed.push(result); + }, + fail: (result) => { + failed.push(result); + }, + fetch: (async (path: string) => { + calls.push({ method: 'GET', path }); + return options.fetchImpl(path, { method: 'GET' }); + }) as CheckContext['fetch'], + post: (async (path: string) => { + calls.push({ method: 'POST', path }); + return options.fetchImpl(path, { method: 'POST' }); + }) as CheckContext['post'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + graphql: (async () => ({})) as CheckContext['graphql'], + } as CheckContext; + + return { ctx, passed, failed, calls }; +} diff --git a/packages/integration-platform/src/manifests/msp360-backup/auth.ts b/packages/integration-platform/src/manifests/msp360-backup/auth.ts new file mode 100644 index 0000000000..658701f220 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/auth.ts @@ -0,0 +1,111 @@ +import type { CheckContext } from '../../types'; +import { + DEFAULT_BACKUP_API_BASE_URL, + type Msp360LoginResponse, +} from './types'; + +export function credString(ctx: CheckContext, key: string, fallback = ''): string { + const value = ctx.credentials[key]; + if (Array.isArray(value)) { + return String(value[0] ?? fallback); + } + if (value == null || value === '') { + return fallback; + } + return String(value); +} + +export function backupBaseUrl(ctx: CheckContext): string { + return credString(ctx, 'baseUrl', DEFAULT_BACKUP_API_BASE_URL).replace(/\/$/, ''); +} + +export function extractAccessToken(payload: unknown): string | null { + if (typeof payload === 'string' && payload.trim()) { + const trimmed = payload.trim().replace(/^"|"$/g, ''); + return trimmed || null; + } + if (!payload || typeof payload !== 'object') { + return null; + } + const record = payload as Record; + const candidates = [ + record.access_token, + record.accessToken, + record.AccessToken, + record.token, + record.Token, + record.providerSessionToken, + ]; + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim(); + } + } + return null; +} + +export function bearerHeaders(token: string): Record { + return { Authorization: `Bearer ${token}` }; +} + +/** + * Custom auth does not attach a Bearer token. Login, then pass Authorization on every call. + */ +export async function loginBackup(ctx: CheckContext): Promise<{ + token: string; + baseUrl: string; +} | null> { + const username = credString(ctx, 'username'); + const password = credString(ctx, 'password'); + const baseUrl = backupBaseUrl(ctx); + + if (!username || !password) { + ctx.fail({ + title: 'Missing MSP360 Backup credentials', + description: + 'Provider Login needs a username and password (Management Console → Settings → General → API).', + resourceType: 'connection', + resourceId: 'msp360-backup', + severity: 'high', + remediation: + 'Reconnect MSP360 Backup and enter the Managed Backup API username and password. Do not use an RMM token here.', + }); + return null; + } + + try { + const response = await ctx.post( + '/api/Provider/Login', + { UserName: username, Password: password }, + { baseUrl }, + ); + const token = extractAccessToken(response); + if (!token) { + ctx.fail({ + title: 'MSP360 Backup login did not return a token', + description: 'POST /api/Provider/Login succeeded but no access token was found in the response.', + resourceType: 'connection', + resourceId: 'msp360-backup', + severity: 'high', + remediation: + 'Confirm the API user can log in at api.mspbackups.com and that the account is not locked.', + evidence: { responseType: typeof response }, + }); + return null; + } + return { token, baseUrl }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'MSP360 Backup login failed', + description: 'Could not authenticate with POST /api/Provider/Login.', + resourceType: 'connection', + resourceId: 'msp360-backup', + severity: 'high', + remediation: + 'Check username/password, API access, and base URL (default https://api.mspbackups.com). Do not send RMM tokens to this integration.', + evidence: { error: message }, + }); + return null; + } +} diff --git a/packages/integration-platform/src/manifests/msp360-backup/checks/app-availability.ts b/packages/integration-platform/src/manifests/msp360-backup/checks/app-availability.ts new file mode 100644 index 0000000000..90d93aeff6 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/checks/app-availability.ts @@ -0,0 +1,54 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { bearerHeaders, loginBackup } from '../auth'; +import type { Msp360Admin } from '../types'; + +export const appAvailabilityCheck: IntegrationCheck = { + id: 'app-availability', + name: 'MSP360 Backup availability', + description: + 'Verify Managed Backup is reachable: Provider Login plus an authenticated API ping. Console HTML is not used because Comp AI fetch expects JSON.', + service: 'availability', + taskMapping: TASK_TEMPLATES.appAvailability, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 Backup app availability check'); + + const session = await loginBackup(ctx); + if (!session) { + return; + } + + try { + await ctx.fetch('/api/Administrators', { + baseUrl: session.baseUrl, + headers: bearerHeaders(session.token), + }); + ctx.pass({ + title: 'MSP360 Backup API is available', + description: + 'Provider Login succeeded and GET /api/Administrators returned a response over HTTPS. That is the availability ping (the management console is HTML, not JSON).', + resourceType: 'service', + resourceId: 'msp360-backup', + evidence: { + login: 'ok', + pingPath: '/api/Administrators', + baseUrl: session.baseUrl, + checkedAt: new Date().toISOString(), + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'MSP360 Backup API ping failed', + description: 'Login worked but GET /api/Administrators failed.', + resourceType: 'service', + resourceId: 'msp360-backup', + severity: 'high', + remediation: + 'Confirm the API user can list administrators and that api.mspbackups.com is reachable from Comp AI.', + evidence: { error: message }, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-backup/checks/backup-logs.ts b/packages/integration-platform/src/manifests/msp360-backup/checks/backup-logs.ts new file mode 100644 index 0000000000..02884812ad --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/checks/backup-logs.ts @@ -0,0 +1,196 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { bearerHeaders, loginBackup } from '../auth'; +import { + daysAgo, + isBackupPlan, + isFailedStatus, + isIncompleteStatus, + isSuccessStatus, + parseMonitoringPayload, + parseTimestamp, + rowId, +} from '../monitoring'; + +const STALE_AFTER_DAYS = 10; + +function failMalformedMonitoring(ctx: CheckContext, payload: unknown, resourceId: string): void { + ctx.fail({ + title: 'MSP360 monitoring payload was not a list', + description: + 'GET /api/Monitoring did not return an array or a known list envelope. Collection failed rather than treating this as empty or not in scope.', + resourceType: 'connection', + resourceId, + severity: 'high', + remediation: 'Confirm the API user can read monitoring data and that Comp AI received JSON from /api/Monitoring.', + evidence: { + payloadType: payload === null ? 'null' : typeof payload, + keys: + payload && typeof payload === 'object' && !Array.isArray(payload) + ? Object.keys(payload as object).slice(0, 20) + : null, + }, + }); +} + +/** + * Comp AI backup-logs wants ~10 consecutive days of job history. MBS GET /api/Monitoring + * is latest run only. Failed/error jobs fail. Successful recent jobs pass. A successful + * latest run older than 10 days is treated as paused / not in scope, not as a failed job. + * Running (3) and Unknown (4) are incomplete — not paused. + */ +export const backupLogsCheck: IntegrationCheck = { + id: 'backup-logs', + name: 'MSP360 backup logs (latest monitoring)', + description: + 'Latest backup plan runs from GET /api/Monitoring. Failed and Running/Unknown jobs fail. Success within 10 days passes. Older successful runs are paused / not in scope.', + service: 'backup', + taskMapping: TASK_TEMPLATES.backupLogs, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 Backup logs check'); + + const session = await loginBackup(ctx); + if (!session) { + return; + } + + let payload: unknown; + try { + payload = await ctx.fetch('/api/Monitoring', { + baseUrl: session.baseUrl, + headers: bearerHeaders(session.token), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'Failed to fetch MSP360 monitoring', + description: 'GET /api/Monitoring failed. Backup log evidence cannot be collected.', + resourceType: 'connection', + resourceId: 'msp360-monitoring', + severity: 'high', + remediation: 'Confirm the API user can read monitoring data in the management console.', + evidence: { error: message }, + }); + return; + } + + const parsed = parseMonitoringPayload(payload); + if (!parsed.ok) { + failMalformedMonitoring(ctx, payload, 'msp360-monitoring'); + return; + } + + const rows = parsed.rows; + const backupRows = rows.filter(isBackupPlan); + const checkedAt = new Date().toISOString(); + + ctx.pass({ + title: 'MSP360 backup monitoring summary', + description: `Monitoring returned ${rows.length} latest plan run(s); ${backupRows.length} in-scope backup plan(s). API is latest-run only, not a 10-day log file. For day-by-day history export CSV from Reporting → Backup history if auditors require it.`, + resourceType: 'service', + resourceId: 'msp360-backup-monitoring-summary', + evidence: { + totalRows: rows.length, + backupPlanCount: backupRows.length, + limitation: 'GET /api/Monitoring returns the latest run per plan, not 10 consecutive days', + checkedAt, + }, + }); + + if (backupRows.length === 0) { + ctx.fail({ + title: 'No in-scope MSP360 backup plans', + description: 'Monitoring had no backup-family plans to evaluate.', + resourceType: 'service', + resourceId: 'msp360-backup-plans', + severity: 'medium', + remediation: 'Create backup plans on managed endpoints, then re-run this check.', + evidence: { totalRows: rows.length, checkedAt }, + }); + return; + } + + for (const [index, row] of backupRows.entries()) { + const id = rowId(row, index); + const started = parseTimestamp(row.LastStart); + const ageDays = started ? daysAgo(started) : Number.POSITIVE_INFINITY; + const stale = !started || ageDays > STALE_AFTER_DAYS; + const failed = isFailedStatus(row.Status); + const incomplete = isIncompleteStatus(row.Status); + const success = isSuccessStatus(row.Status); + + const evidence = { + planName: row.PlanName, + computerName: row.ComputerName, + companyName: row.CompanyName, + planType: row.PlanType, + status: row.Status, + lastStart: row.LastStart, + errorMessage: row.ErrorMessage, + detailedReportLink: row.DetailedReportLink, + ageDays: Number.isFinite(ageDays) ? Math.round(ageDays * 10) / 10 : null, + checkedAt, + }; + + if (failed) { + ctx.fail({ + title: `Backup issue: ${row.PlanName ?? id}`, + description: `Latest run status is ${String(row.Status)}${row.ErrorMessage ? `: ${row.ErrorMessage}` : ''}.`, + resourceType: 'backup-plan', + resourceId: id, + severity: 'high', + remediation: + 'Open the detailed report in the management console, fix the plan error, and re-run.', + evidence, + }); + continue; + } + + if (incomplete) { + ctx.fail({ + title: `Backup not completed: ${row.PlanName ?? id}`, + description: `Latest status is ${String(row.Status)} (Running or Unknown). That is not a successful completed run and is not treated as paused.`, + resourceType: 'backup-plan', + resourceId: id, + severity: 'medium', + remediation: 'Wait for the job to finish or inspect why the plan status is unknown, then re-run.', + evidence: { ...evidence, outcome: 'incomplete' }, + }); + continue; + } + + if (success && !stale) { + ctx.pass({ + title: `Backup ok: ${row.PlanName ?? id}`, + description: `Latest run succeeded and LastStart is within ${STALE_AFTER_DAYS} days.`, + resourceType: 'backup-plan', + resourceId: id, + evidence, + }); + continue; + } + + if (success && stale) { + ctx.pass({ + title: `Backup paused / not in scope: ${row.PlanName ?? id}`, + description: `Latest run succeeded, but LastStart is missing or older than ${STALE_AFTER_DAYS} days (LastStart=${row.LastStart ?? 'n/a'}). Treated as paused / not currently in scope, not as a failed backup. Re-enable the plan if it should still protect production data.`, + resourceType: 'backup-plan', + resourceId: id, + evidence: { ...evidence, outcome: 'paused-not-in-scope' }, + }); + continue; + } + + ctx.fail({ + title: `Backup status not a completed success: ${row.PlanName ?? id}`, + description: `Latest status is ${String(row.Status ?? 'missing')}. Only Success can pass or be treated as paused.`, + resourceType: 'backup-plan', + resourceId: id, + severity: 'medium', + remediation: 'Inspect the plan in the management console and re-run after it completes successfully.', + evidence, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-backup/checks/backup-restoration-test.ts b/packages/integration-platform/src/manifests/msp360-backup/checks/backup-restoration-test.ts new file mode 100644 index 0000000000..6512b3ea8e --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/checks/backup-restoration-test.ts @@ -0,0 +1,145 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { bearerHeaders, loginBackup } from '../auth'; +import { + daysAgo, + isRestorePlan, + isSuccessStatus, + parseMonitoringPayload, + parseTimestamp, + rowId, +} from '../monitoring'; + +const RESTORE_WINDOW_DAYS = 90; + +function inRestoreWindow(row: { LastStart?: string }): boolean { + const started = parseTimestamp(row.LastStart); + return !!started && daysAgo(started) <= RESTORE_WINDOW_DAYS; +} + +export const backupRestorationTestCheck: IntegrationCheck = { + id: 'backup-restoration-test', + name: 'MSP360 backup restoration test', + description: + 'Pass if a successful restore or restore-verification ran in the last 90 days. Fail every in-window restore that is not success (including missing status). N/A only when there is no restore-family job in that window.', + service: 'backup', + taskMapping: TASK_TEMPLATES.backupRestorationTest, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 Backup restoration-test check'); + + const session = await loginBackup(ctx); + if (!session) { + return; + } + + let payload: unknown; + try { + payload = await ctx.fetch('/api/Monitoring', { + baseUrl: session.baseUrl, + headers: bearerHeaders(session.token), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'Failed to fetch MSP360 monitoring for restore tests', + description: 'GET /api/Monitoring failed.', + resourceType: 'connection', + resourceId: 'msp360-restore-monitoring', + severity: 'high', + remediation: 'Confirm the API user can read monitoring data.', + evidence: { error: message }, + }); + return; + } + + const parsed = parseMonitoringPayload(payload); + if (!parsed.ok) { + ctx.fail({ + title: 'MSP360 monitoring payload was not a list', + description: + 'GET /api/Monitoring did not return an array or a known list envelope. Collection failed rather than treating this as restore not in scope.', + resourceType: 'connection', + resourceId: 'msp360-restore-monitoring', + severity: 'high', + remediation: 'Confirm Comp AI received JSON from /api/Monitoring.', + evidence: { + payloadType: payload === null ? 'null' : typeof payload, + keys: + payload && typeof payload === 'object' && !Array.isArray(payload) + ? Object.keys(payload as object).slice(0, 20) + : null, + }, + }); + return; + } + + const rows = parsed.rows; + const restoreRows = rows.filter(isRestorePlan); + const checkedAt = new Date().toISOString(); + + const recentSuccess = restoreRows.filter( + (row) => inRestoreWindow(row) && isSuccessStatus(row.Status), + ); + // Missing or unrecognized status in-window is not N/A — it is a failed/indeterminate restore. + const recentFailed = restoreRows.filter( + (row) => inRestoreWindow(row) && !isSuccessStatus(row.Status), + ); + + if (restoreRows.length === 0 || (recentSuccess.length === 0 && recentFailed.length === 0)) { + ctx.pass({ + title: 'MSP360 restore test not in scope', + description: `Monitoring has ${restoreRows.length} restore-family row(s) and none with a LastStart within ${RESTORE_WINDOW_DAYS} days. This is a process control, not a failed restore. Run a restore or enable Restore Verification when it is in scope.`, + resourceType: 'control', + resourceId: 'msp360-restore-not-in-scope', + evidence: { + restoreRowCount: restoreRows.length, + restoreRows: restoreRows.slice(0, 25), + windowDays: RESTORE_WINDOW_DAYS, + outcome: 'not-in-scope', + checkedAt, + }, + }); + return; + } + + for (const [index, row] of recentFailed.entries()) { + ctx.fail({ + title: `Restore test failed: ${row.PlanName ?? rowId(row, index)}`, + description: `Restore-family plan within ${RESTORE_WINDOW_DAYS} days did not succeed (Status=${String(row.Status)}, LastStart=${row.LastStart}).`, + resourceType: 'restore-plan', + resourceId: rowId(row, index), + severity: 'high', + remediation: 'Fix the restore or restore-verification plan and re-run it successfully.', + evidence: { + planName: row.PlanName, + computerName: row.ComputerName, + planType: row.PlanType, + status: row.Status, + lastStart: row.LastStart, + errorMessage: row.ErrorMessage, + detailedReportLink: row.DetailedReportLink, + checkedAt, + }, + }); + } + + for (const [index, row] of recentSuccess.entries()) { + ctx.pass({ + title: `Restore test ok: ${row.PlanName ?? rowId(row, index)}`, + description: `Successful restore-family plan within ${RESTORE_WINDOW_DAYS} days (LastStart=${row.LastStart}).`, + resourceType: 'restore-plan', + resourceId: rowId(row, index), + evidence: { + planName: row.PlanName, + computerName: row.ComputerName, + planType: row.PlanType, + status: row.Status, + lastStart: row.LastStart, + detailedReportLink: row.DetailedReportLink, + checkedAt, + }, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-backup/checks/employee-access.ts b/packages/integration-platform/src/manifests/msp360-backup/checks/employee-access.ts new file mode 100644 index 0000000000..a9900930a7 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/checks/employee-access.ts @@ -0,0 +1,109 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { bearerHeaders, loginBackup } from '../auth'; +import type { Msp360Admin } from '../types'; + +function asAdminList(payload: unknown): Msp360Admin[] { + if (Array.isArray(payload)) { + return payload as Msp360Admin[]; + } + if (payload && typeof payload === 'object') { + const record = payload as Record; + for (const key of ['data', 'items', 'Administrators']) { + if (Array.isArray(record[key])) { + return record[key] as Msp360Admin[]; + } + } + } + return []; +} + +function adminEmail(admin: Msp360Admin, index: number): string { + const email = (admin.Email ?? '').trim(); + return email ? email.toLowerCase() : `admin-${admin.AdminID ?? index}`; +} + +/** + * Staff access = GET /api/Administrators only. + * GET /api/Users is backup customers and must never be treated as employees. + */ +export const employeeAccessCheck: IntegrationCheck = { + id: 'employee-access', + name: 'MSP360 Backup administrators', + description: + 'List Managed Backup console administrators (staff). Does not call GET /api/Users (those are backup customers).', + service: 'user-sync', + taskMapping: TASK_TEMPLATES.employeeAccess, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 Backup employee-access check (Administrators only)'); + + const session = await loginBackup(ctx); + if (!session) { + return; + } + + let payload: unknown; + try { + payload = await ctx.fetch('/api/Administrators', { + baseUrl: session.baseUrl, + headers: bearerHeaders(session.token), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'Failed to list MSP360 administrators', + description: 'GET /api/Administrators failed. Employee access evidence requires this roster.', + resourceType: 'connection', + resourceId: 'msp360-backup-admins', + severity: 'high', + remediation: + 'Grant the API user permission to list administrators. Do not substitute GET /api/Users — that list is customers, not staff.', + evidence: { error: message }, + }); + return; + } + + const admins = asAdminList(payload); + const checkedAt = new Date().toISOString(); + + if (admins.length === 0) { + ctx.fail({ + title: 'No MSP360 administrators returned', + description: 'GET /api/Administrators returned an empty list.', + resourceType: 'connection', + resourceId: 'msp360-backup-admins', + severity: 'medium', + remediation: 'Confirm the API user can see administrator accounts in the management console.', + evidence: { checkedAt }, + }); + return; + } + + for (const [index, admin] of admins.entries()) { + const email = adminEmail(admin, index); + const name = `${admin.FirstName ?? ''} ${admin.LastName ?? ''}`.trim() || email; + const enabled = admin.Enabled !== false; + ctx.pass({ + title: 'Employee Access', + resourceType: 'user', + resourceId: email, + description: `${name} is an MSP360 Backup administrator (${enabled ? 'enabled' : 'disabled'}). Disabled admins are still listed for an honest roster.`, + evidence: { + email: admin.Email, + firstName: admin.FirstName, + lastName: admin.LastName, + enabled, + lastLogin: admin.LastLogin, + dateCreated: admin.DateCreated, + companies: admin.Companies, + permissions: admin.PermissionsModels, + source: 'GET /api/Administrators', + checkedAt, + }, + }); + } + + ctx.log(`MSP360 Backup employee-access complete: ${admins.length} administrators`); + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-backup/checks/index.ts b/packages/integration-platform/src/manifests/msp360-backup/checks/index.ts new file mode 100644 index 0000000000..18bca1446e --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/checks/index.ts @@ -0,0 +1,4 @@ +export { appAvailabilityCheck } from './app-availability'; +export { backupLogsCheck } from './backup-logs'; +export { backupRestorationTestCheck } from './backup-restoration-test'; +export { employeeAccessCheck } from './employee-access'; diff --git a/packages/integration-platform/src/manifests/msp360-backup/index.ts b/packages/integration-platform/src/manifests/msp360-backup/index.ts new file mode 100644 index 0000000000..e3f8e9af60 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/index.ts @@ -0,0 +1,95 @@ +import type { IntegrationManifest } from '../../types'; +import { + appAvailabilityCheck, + backupLogsCheck, + backupRestorationTestCheck, + employeeAccessCheck, +} from './checks'; + +export const msp360BackupManifest: IntegrationManifest = { + id: 'msp360-backup', + name: 'MSP360 Backup', + description: + 'Collect Managed Backup evidence: console/API availability, administrator access, latest backup runs, and restore tests. Uses Provider Login — not the RMM token.', + category: 'Monitoring', + logoUrl: 'https://images.msp360.com/bimi/msp360-logo.svg', + docsUrl: 'https://help.mspbackups.com/mbs-api-specification/managed-backup-api/methods/api-methods', + isActive: true, + supportsMultipleConnections: false, + + baseUrl: 'https://api.mspbackups.com', + defaultHeaders: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + + auth: { + type: 'custom', + config: { + description: + 'Managed Backup Provider Login (username + password). Never paste an RMM API token into this connection.', + credentialFields: [ + { + id: 'username', + label: 'API username', + type: 'text', + required: true, + placeholder: 'api-user@example.com', + helpText: 'Management Console → Settings → General → API login (Provider Login).', + }, + { + id: 'password', + label: 'API password', + type: 'password', + required: true, + helpText: 'Password for the Managed Backup API user. Not an RMM token.', + }, + { + id: 'baseUrl', + label: 'Backup API base URL', + type: 'url', + required: false, + placeholder: 'https://api.mspbackups.com', + helpText: 'Leave blank for https://api.mspbackups.com unless MSP360 gave you a regional host.', + }, + ], + setupInstructions: `Connect MSP360 Backup (not RMM): + +1. In the MSP360 / CloudBerry Management Console go to Settings → General → API. +2. Copy the API username and password used for Provider Login (POST /api/Provider/Login). +3. Paste them here. Do not use an RMM API token — that belongs on the separate MSP360 RMM integration. +4. Employee access is GET /api/Administrators only. GET /api/Users is backup customers and is never used as staff.`, + }, + }, + + capabilities: ['checks'], + + services: [ + { + id: 'availability', + name: 'Availability', + description: 'Provider Login plus authenticated API ping', + enabledByDefault: true, + implemented: true, + }, + { + id: 'user-sync', + name: 'Administrators', + description: 'Console administrator roster (not backup customers)', + enabledByDefault: true, + implemented: true, + }, + { + id: 'backup', + name: 'Backup monitoring', + description: 'Latest plan runs and restore-test filter', + enabledByDefault: true, + implemented: true, + }, + ], + + checks: [appAvailabilityCheck, employeeAccessCheck, backupLogsCheck, backupRestorationTestCheck], +}; + +export default msp360BackupManifest; +export * from './types'; diff --git a/packages/integration-platform/src/manifests/msp360-backup/monitoring.ts b/packages/integration-platform/src/manifests/msp360-backup/monitoring.ts new file mode 100644 index 0000000000..10c0906df5 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/monitoring.ts @@ -0,0 +1,126 @@ +import { + BACKUP_PLAN_TYPE_VALUES, + FAILED_STATUS_VALUES, + INCOMPLETE_STATUS_VALUES, + RESTORE_PLAN_TYPE_VALUES, + SUCCESS_STATUS_VALUES, + type Msp360MonitoringRow, +} from './types'; + +export type ParsedMonitoring = + | { ok: true; rows: Msp360MonitoringRow[] } + | { ok: false }; + +/** Array or a known list envelope. Anything else is malformed — do not treat as empty/N/A. */ +export function parseMonitoringPayload(payload: unknown): ParsedMonitoring { + if (Array.isArray(payload)) { + return { ok: true, rows: payload as Msp360MonitoringRow[] }; + } + if (payload && typeof payload === 'object') { + const record = payload as Record; + for (const key of ['data', 'items', 'results', 'Monitoring']) { + if (Array.isArray(record[key])) { + return { ok: true, rows: record[key] as Msp360MonitoringRow[] }; + } + } + return { ok: false }; + } + return { ok: false }; +} + +function numericOrName(value: unknown): { n: number | null; name: string } { + if (typeof value === 'number' && Number.isFinite(value)) { + return { n: value, name: String(value) }; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) { + return { n: Number(trimmed), name: trimmed }; + } + return { n: null, name: trimmed }; + } + return { n: null, name: '' }; +} + +export function isRestorePlan(row: Msp360MonitoringRow): boolean { + const { n, name } = numericOrName(row.PlanType); + // Numeric type wins: a backup-family id is never a restore just because the plan name says so. + if (n != null) { + return RESTORE_PLAN_TYPE_VALUES.has(n); + } + const blob = `${name} ${row.PlanName ?? ''}`; + // Docs spell SQL restore as SQLResore (missing t). + return /sqlresore/i.test(blob) || /restore/i.test(blob) || /verif/i.test(blob); +} + +export function isBackupPlan(row: Msp360MonitoringRow): boolean { + if (isRestorePlan(row)) { + return false; + } + const { n, name } = numericOrName(row.PlanType); + if (n != null) { + return BACKUP_PLAN_TYPE_VALUES.has(n); + } + if (!name || /^n\/?a$/i.test(name)) { + return false; + } + if (/consistenc/i.test(name)) { + return false; + } + return /backup/i.test(name) || /backup/i.test(row.PlanName ?? ''); +} + +export function isSuccessStatus(status: unknown): boolean { + if (typeof status === 'number') { + return SUCCESS_STATUS_VALUES.has(status); + } + if (typeof status === 'string') { + const key = status.trim().toLowerCase(); + return SUCCESS_STATUS_VALUES.has(key) || SUCCESS_STATUS_VALUES.has(status); + } + return false; +} + +export function isFailedStatus(status: unknown): boolean { + if (isSuccessStatus(status) || isIncompleteStatus(status)) { + return false; + } + if (typeof status === 'number') { + return FAILED_STATUS_VALUES.has(status); + } + if (typeof status === 'string') { + const key = status.trim().toLowerCase(); + return FAILED_STATUS_VALUES.has(key) || /fail|error|overdue|interrupt/i.test(key); + } + return false; +} + +export function isIncompleteStatus(status: unknown): boolean { + if (typeof status === 'number') { + return INCOMPLETE_STATUS_VALUES.has(status); + } + if (typeof status === 'string') { + const key = status.trim().toLowerCase(); + return INCOMPLETE_STATUS_VALUES.has(key); + } + return false; +} + +export function parseTimestamp(value: string | undefined): Date | null { + if (!value) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function daysAgo(from: Date, now = new Date()): number { + return (now.getTime() - from.getTime()) / (1000 * 60 * 60 * 24); +} + +export function rowId(row: Msp360MonitoringRow, index: number): string { + if (row.PlanId) { + return row.PlanId; + } + return `${row.ComputerName ?? 'host'}:${row.PlanName ?? 'plan'}:${index}`; +} diff --git a/packages/integration-platform/src/manifests/msp360-backup/types.ts b/packages/integration-platform/src/manifests/msp360-backup/types.ts new file mode 100644 index 0000000000..432ad54e69 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-backup/types.ts @@ -0,0 +1,93 @@ +/** MSP360 Managed Backup (MBS) API types. Field names follow the public swagger. */ + +export interface Msp360LoginRequest { + UserName: string; + Password: string; +} + +export interface Msp360LoginResponse { + access_token?: string; + accessToken?: string; + AccessToken?: string; + token?: string; + Token?: string; +} + +export interface Msp360Admin { + AdminID?: string; + Email?: string; + FirstName?: string; + LastName?: string; + Enabled?: boolean; + LastLogin?: string; + DateCreated?: string; + Companies?: string[]; + PermissionsModels?: Record; +} + +/** + * MonitoringPlanType (see MSP360 docs). Restore family: 2, 4, 6, 8, 10, 12, 15, 17. + * SQLResore is the documented spelling for value 8. + */ +export type Msp360PlanType = number | string; + +/** + * MonitoringPlanStatus: Success=0, Overdue=1, Error=2, Running=3, Unknown=4, + * Interrupted=5, UnexpectedlyClosed=6, Warning=7 + */ +export type Msp360PlanStatus = number | string; + +export interface Msp360MonitoringRow { + PlanName?: string; + CompanyName?: string; + UserName?: string; + UserID?: string; + ComputerName?: string; + LastStart?: string; + NextStart?: string; + Status?: Msp360PlanStatus; + ErrorMessage?: string; + PlanId?: string; + PlanType?: Msp360PlanType; + DetailedReportLink?: string; +} + +export const DEFAULT_BACKUP_API_BASE_URL = 'https://api.mspbackups.com'; + +/** Restore / restore-verification family (MonitoringPlanType). */ +export const RESTORE_PLAN_TYPE_VALUES = new Set([2, 4, 6, 8, 10, 12, 15, 17]); + +/** Backup family (excludes restore, NA, consistency check). */ +export const BACKUP_PLAN_TYPE_VALUES = new Set([1, 3, 5, 7, 9, 11, 14, 16]); + +export const SUCCESS_STATUS_VALUES = new Set([0, '0', 'success', 'succeeded', 'completed', 'ok']); +export const FAILED_STATUS_VALUES = new Set([ + 1, + 2, + 5, + 6, + 7, + '1', + '2', + '5', + '6', + '7', + 'overdue', + 'error', + 'failed', + 'interrupted', + 'unexpectedlyclosed', + 'warning', +]); + +/** Running=3 and Unknown=4 are not completed success and must not be scored as paused/N/A. */ +export const INCOMPLETE_STATUS_VALUES = new Set([ + 3, + 4, + '3', + '4', + 'running', + 'unknown', + 'inprogress', + 'in progress', +]); diff --git a/packages/integration-platform/src/manifests/msp360-rmm/__tests__/checks.test.ts b/packages/integration-platform/src/manifests/msp360-rmm/__tests__/checks.test.ts new file mode 100644 index 0000000000..7cb3b60c1d --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/__tests__/checks.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from 'bun:test'; +import type { CheckContext, CheckFindingResult, CheckPassingResult } from '../../../types'; +import { deviceListCheck } from '../checks/device-list'; +import { infrastructureInventoryCheck } from '../checks/infrastructure-inventory'; +import { monitoringAlertingCheck } from '../checks/monitoring-alerting'; +import { secureDevicesCheck } from '../checks/secure-devices'; + +function makeRmmCtx(fetchImpl: (path: string) => Promise): { + ctx: CheckContext; + passed: CheckPassingResult[]; + failed: CheckFindingResult[]; +} { + const passed: CheckPassingResult[] = []; + const failed: CheckFindingResult[] = []; + const ctx: CheckContext = { + accessToken: '', + credentials: { api_key: 'rmm-token' }, + variables: {}, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: {}, + log: () => {}, + warn: () => {}, + error: () => {}, + pass: (result) => { + passed.push(result); + }, + fail: (result) => { + failed.push(result); + }, + fetch: (async (path: string) => fetchImpl(path)) as CheckContext['fetch'], + post: (async () => { + throw new Error('RMM checks should not POST'); + }) as CheckContext['post'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + graphql: (async () => ({})) as CheckContext['graphql'], + } as CheckContext; + return { ctx, passed, failed }; +} + +function page(data: unknown[]) { + return { data, total: data.length }; +} + +describe('msp360-rmm deviceListCheck', () => { + it('passes one device row per hid', async () => { + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return page([{ hid: 'h1', computerName: 'laptop-1', os: 'Windows 11' }]); + } + throw new Error(path); + }); + await deviceListCheck.run(ctx); + expect(failed).toHaveLength(0); + expect(passed.some((r) => r.resourceType === 'device' && r.resourceId === 'h1')).toBe(true); + }); + + it('uses HostName when computerName is missing', async () => { + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return page([{ hid: 'h-host', HostName: 'WIN-ONLY-HOSTNAME' }]); + } + throw new Error(path); + }); + await deviceListCheck.run(ctx); + expect(failed).toHaveLength(0); + expect(passed.some((r) => r.title.includes('WIN-ONLY-HOSTNAME'))).toBe(true); + }); + + it('fails when the host list is empty', async () => { + const { ctx, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) return page([]); + throw new Error(path); + }); + await deviceListCheck.run(ctx); + expect(failed.length).toBeGreaterThan(0); + }); +}); + +describe('msp360-rmm secureDevicesCheck', () => { + it('passes hosts with active AV and does not invent BitLocker', async () => { + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) return page([{ hid: 'h1', computerName: 'laptop-1' }]); + if (path.includes('/antivirus/')) { + return page([{ hid: 'h1', productName: 'Defender', enabled: true }]); + } + if (path.includes('/summary/')) return page([{ hid: 'h1' }]); + throw new Error(path); + }); + await secureDevicesCheck.run(ctx); + expect(failed.filter((r) => r.resourceId === 'h1')).toHaveLength(0); + expect(passed.some((r) => r.resourceId === 'h1')).toBe(true); + expect(passed.some((r) => r.resourceId === 'msp360-rmm-unverified-encryption-screenlock')).toBe( + true, + ); + }); + + it('fails Windows hosts with no antivirus', async () => { + const { ctx, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return page([{ hid: 'h2', computerName: 'bare', osName: 'Windows 11', operationSystemID: 'Windows' }]); + } + if (path.includes('/antivirus/')) return page([]); + if (path.includes('/summary/')) return page([]); + throw new Error(path); + }); + await secureDevicesCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'h2')).toBe(true); + }); + + it('passes Linux hosts without antivirus as not applicable', async () => { + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return page([ + { + hid: 'linux-1', + computerName: 'hetzner', + osName: 'Debian GNU/Linux 13', + operationSystemID: 'Linux', + platformID: 'Unix', + }, + ]); + } + if (path.includes('/antivirus/')) return page([]); + if (path.includes('/summary/')) return page([]); + throw new Error(path); + }); + await secureDevicesCheck.run(ctx); + expect(failed.filter((r) => r.resourceId === 'linux-1')).toHaveLength(0); + expect(passed.some((r) => r.resourceId === 'linux-1')).toBe(true); + }); + + it('fails a Linux host when encryption is explicitly off, before AV N/A', async () => { + const { ctx, failed, passed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return page([ + { + hid: 'linux-enc', + computerName: 'hetzner', + osName: 'Debian GNU/Linux 13', + operationSystemID: 'Linux', + encryption: 'disabled', + }, + ]); + } + if (path.includes('/antivirus/')) return page([]); + if (path.includes('/summary/')) return page([]); + throw new Error(path); + }); + await secureDevicesCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'linux-enc' && r.title.includes('encryption'))).toBe( + true, + ); + expect(passed.some((r) => r.resourceId === 'linux-enc')).toBe(false); + }); + + it('fails when screen lock is explicitly off', async () => { + const { ctx, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return page([{ hid: 'h-lock', computerName: 'laptop-1', osName: 'Windows 11' }]); + } + if (path.includes('/antivirus/')) { + return page([{ hid: 'h-lock', productName: 'Defender', enabled: true, screenLock: false }]); + } + if (path.includes('/summary/')) return page([{ hid: 'h-lock' }]); + throw new Error(path); + }); + await secureDevicesCheck.run(ctx); + expect(failed.some((r) => r.resourceId === 'h-lock' && r.title.includes('Screen lock'))).toBe( + true, + ); + }); + + it('reads enabled AV from nested header/data envelopes', async () => { + const hid = '6e6437dd-2fc8-427d-a220-6ff5926bedea'; + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return { + items: [{ header: { hid, computerName: 'laptop-1' }, data: [{ computerName: 'laptop-1' }] }], + total: 1, + }; + } + if (path.includes('/antivirus/')) { + return { + items: [ + { + header: { hid: `{${hid.toUpperCase()}}`, computerName: 'laptop-1' }, + data: [{ displayName: 'Windows Defender', enabled: true }], + }, + ], + total: 1, + }; + } + if (path.includes('/summary/')) return { items: [], total: 0 }; + throw new Error(path); + }); + await secureDevicesCheck.run(ctx); + expect(failed.filter((r) => r.resourceId === hid)).toHaveLength(0); + expect(passed.some((r) => r.resourceId === hid)).toBe(true); + }); +}); + +describe('msp360-rmm monitoringAlertingCheck', () => { + it('passes when summary returns rows even if alerts are open', async () => { + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/summary/')) { + return page([{ hid: 'h1', alerts: [{ severity: 'critical', message: 'disk' }] }]); + } + throw new Error(path); + }); + await monitoringAlertingCheck.run(ctx); + expect(failed).toHaveLength(0); + expect(passed.length).toBeGreaterThan(0); + }); +}); + +describe('msp360-rmm infrastructureInventoryCheck', () => { + it('joins hardware and software by hid', async () => { + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) return page([{ hid: 'h1', computerName: 'srv' }]); + if (path.includes('/hardware/')) return page([{ hid: 'h1', name: 'Disk0' }]); + if (path.includes('/software/')) return page([{ hid: 'h1', name: 'Chrome' }]); + throw new Error(path); + }); + await infrastructureInventoryCheck.run(ctx); + expect(failed).toHaveLength(0); + const device = passed.find((r) => r.resourceId === 'h1'); + expect(device?.evidence).toMatchObject({ hid: 'h1' }); + }); + + it('joins live envelope rows when hid braces differ', async () => { + const hidBare = '3c934b6b-a47b-43f7-a458-c4d5610468b7'; + const hidBraced = '{3C934B6B-A47B-43F7-A458-C4D5610468B7}'; + const { ctx, passed, failed } = makeRmmCtx(async (path) => { + if (path.includes('/host/')) { + return { items: [{ header: { hid: hidBare, computerName: 'WIN-1' }, data: [{ computerName: 'WIN-1', osName: 'Windows' }] }], total: 1 }; + } + if (path.includes('/hardware/')) { + return { items: [{ header: { hid: hidBraced, computerName: 'WIN-1' }, data: [{ name: 'Disk0' }] }], total: 1 }; + } + if (path.includes('/software/')) { + return { items: [{ header: { hid: hidBraced, computerName: 'WIN-1' }, data: [{ name: 'Chrome' }] }], total: 1 }; + } + throw new Error(path); + }); + await infrastructureInventoryCheck.run(ctx); + expect(failed).toHaveLength(0); + const device = passed.find((r) => r.resourceId === hidBare); + expect(device?.title).toContain('WIN-1'); + expect((device?.evidence as { hardware?: unknown[] } | undefined)?.hardware).toHaveLength(1); + }); +}); diff --git a/packages/integration-platform/src/manifests/msp360-rmm/__tests__/client.test.ts b/packages/integration-platform/src/manifests/msp360-rmm/__tests__/client.test.ts new file mode 100644 index 0000000000..678071b71c --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/__tests__/client.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'bun:test'; +import type { CheckContext } from '../../../types'; +import { expandStatRows, fetchAllStat, hidOf, normalizeHid, pageRows } from '../client'; + +describe('msp360-rmm client parsing', () => { + it('normalizes braced and mixed-case hids', () => { + expect(normalizeHid('{3C934B6B-A47B-43F7-A458-C4D5610468B7}')).toBe( + '3c934b6b-a47b-43f7-a458-c4d5610468b7', + ); + expect(hidOf({ header: { hid: '{ABC}' } })).toBe('abc'); + }); + + it('reads page rows from items (live RMM shape)', () => { + const { rows, total } = pageRows({ items: [{ hid: 'h1' }], total: 1, pageNumber: 1, pageSize: 100 }); + expect(rows).toHaveLength(1); + expect(total).toBe(1); + }); + + it('does not treat a bare array length as a fleet total', () => { + const page = Array.from({ length: 100 }, (_, i) => ({ hid: `h${i}` })); + const { rows, total } = pageRows(page); + expect(rows).toHaveLength(100); + expect(total).toBeNull(); + }); + + it('keeps paging a full first page when the API omits total', async () => { + const page1 = Array.from({ length: 100 }, (_, i) => ({ hid: `a${i}`, computerName: `a${i}` })); + const page2 = [{ hid: 'b1', computerName: 'last' }]; + const pages: Record = { '1': page1, '2': page2 }; + const ctx = { + credentials: { api_key: 't', baseUrl: 'https://api.rmm.mspbackups.com' }, + log: () => {}, + warn: () => {}, + fail: () => {}, + fetch: async (_path: string, init?: { params?: Record }) => { + const n = init?.params?.pageNumber ?? '1'; + return pages[n] ?? []; + }, + } as unknown as CheckContext; + const rows = await fetchAllStat(ctx, 'host'); + expect(rows.length).toBe(101); + expect(rows.some((r) => r.hid === 'b1')).toBe(true); + }); + + it('does not report truncation when a full last page is the end of the fleet', async () => { + const pages: Record = { + '1': Array.from({ length: 100 }, (_, i) => ({ hid: `p1-${i}` })), + '2': Array.from({ length: 100 }, (_, i) => ({ hid: `p2-${i}` })), + '3': [], + }; + const failed: Array<{ resourceId?: string }> = []; + const ctx = { + credentials: { api_key: 't', baseUrl: 'https://api.rmm.mspbackups.com' }, + log: () => {}, + warn: () => {}, + fail: (result: { resourceId?: string }) => { + failed.push(result); + }, + fetch: async (_path: string, init?: { params?: Record }) => { + const n = init?.params?.pageNumber ?? '1'; + return pages[n] ?? []; + }, + } as unknown as CheckContext; + const rows = await fetchAllStat(ctx, 'host', { maxPages: 2 }); + expect(rows).toHaveLength(200); + expect(failed.some((r) => r.resourceId === 'msp360-rmm-host-truncated')).toBe(false); + }); + + it('reports truncation only after a probe page still has new hosts', async () => { + const pages: Record = { + '1': Array.from({ length: 100 }, (_, i) => ({ hid: `p1-${i}` })), + '2': Array.from({ length: 100 }, (_, i) => ({ hid: `p2-${i}` })), + '3': [{ hid: 'overflow', computerName: 'more' }], + }; + const failed: Array<{ resourceId?: string; evidence?: { pageCap?: number }; description?: string }> = + []; + const ctx = { + credentials: { api_key: 't', baseUrl: 'https://api.rmm.mspbackups.com' }, + log: () => {}, + warn: () => {}, + fail: (result: { resourceId?: string; evidence?: { pageCap?: number }; description?: string }) => { + failed.push(result); + }, + fetch: async (_path: string, init?: { params?: Record }) => { + const n = init?.params?.pageNumber ?? '1'; + return pages[n] ?? []; + }, + } as unknown as CheckContext; + const rows = await fetchAllStat(ctx, 'host', { maxPages: 2 }); + expect(rows.some((r) => r.hid === 'overflow')).toBe(true); + const truncation = failed.find((r) => r.resourceId === 'msp360-rmm-host-truncated'); + expect(truncation?.evidence?.pageCap).toBe(2); + expect(truncation?.description).toContain('2 pages'); + }); + + it('flattens header/data envelopes onto plugin rows', () => { + const expanded = expandStatRows([ + { + header: { hid: '{H1}', computerName: 'hetzner' }, + data: [{ osName: 'Debian', network: { ip4Address: '10.0.0.1', macAddress: 'aa' } }], + }, + ]); + expect(expanded).toHaveLength(1); + expect(expanded[0]?.hid).toBe('h1'); + expect(expanded[0]?.computerName).toBe('hetzner'); + expect(expanded[0]?.osName).toBe('Debian'); + expect(expanded[0]?.ip).toBe('10.0.0.1'); + }); +}); diff --git a/packages/integration-platform/src/manifests/msp360-rmm/checks/device-list.ts b/packages/integration-platform/src/manifests/msp360-rmm/checks/device-list.ts new file mode 100644 index 0000000000..44f07b27f7 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/checks/device-list.ts @@ -0,0 +1,82 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { fetchAllStat, hidOf, hostName, pickString, rmmToken } from '../client'; +import type { RmmRecord } from '../types'; + +export const deviceListCheck: IntegrationCheck = { + id: 'device-list', + name: 'MSP360 RMM device list', + description: 'Fleet host inventory from GET /api/v1/computers/stat/host/latest (paged).', + service: 'inventory', + taskMapping: TASK_TEMPLATES.deviceList, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 RMM device-list check'); + if (!rmmToken(ctx)) { + ctx.fail({ + title: 'Missing MSP360 RMM API token', + description: 'This integration needs a Bearer token from Settings → General → RMM API tokens.', + resourceType: 'connection', + resourceId: 'msp360-rmm', + severity: 'high', + remediation: + 'Create an RMM API token for an administrator who has an RMM license. Community Edition has no API. Do not use Backup Provider Login here.', + }); + return; + } + + let hosts: RmmRecord[]; + try { + hosts = await fetchAllStat(ctx, 'host'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'Failed to fetch MSP360 RMM hosts', + description: 'The fleet host endpoint returned an error or unauthorized response.', + resourceType: 'connection', + resourceId: 'msp360-rmm-hosts', + severity: 'high', + remediation: 'Confirm the RMM token, license, and base URL (https://api.rmm.mspbackups.com).', + evidence: { error: message }, + }); + return; + } + + if (hosts.length === 0) { + ctx.fail({ + title: 'MSP360 RMM returned no devices', + description: 'Host inventory was empty.', + resourceType: 'connection', + resourceId: 'msp360-rmm-hosts', + severity: 'medium', + remediation: 'Confirm RMM agents are installed and the token can read computer stats.', + }); + return; + } + + const checkedAt = new Date().toISOString(); + for (const [index, host] of hosts.entries()) { + const hid = hidOf(host, `host-${index}`); + const name = hostName(host, hid); + ctx.pass({ + title: `Device: ${name}`, + description: 'Listed from MSP360 RMM host inventory.', + resourceType: 'device', + resourceId: hid, + evidence: { + hid, + name, + os: pickString(host, ['os', 'OS', 'osName', 'operatingSystem']), + manufacturer: pickString(host, ['manufacturer', 'Manufacturer']), + model: pickString(host, ['model', 'Model']), + serial: pickString(host, ['serial', 'Serial', 'serialNumber', 'SerialNumber']), + ip: pickString(host, ['ip', 'IP', 'ipAddress', 'IpAddress']), + mac: pickString(host, ['mac', 'MAC', 'macAddress']), + location: pickString(host, ['location', 'Location']), + raw: host, + checkedAt, + }, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-rmm/checks/index.ts b/packages/integration-platform/src/manifests/msp360-rmm/checks/index.ts new file mode 100644 index 0000000000..b5a012f16f --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/checks/index.ts @@ -0,0 +1,4 @@ +export { deviceListCheck } from './device-list'; +export { infrastructureInventoryCheck } from './infrastructure-inventory'; +export { monitoringAlertingCheck } from './monitoring-alerting'; +export { secureDevicesCheck } from './secure-devices'; diff --git a/packages/integration-platform/src/manifests/msp360-rmm/checks/infrastructure-inventory.ts b/packages/integration-platform/src/manifests/msp360-rmm/checks/infrastructure-inventory.ts new file mode 100644 index 0000000000..74175fff5f --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/checks/infrastructure-inventory.ts @@ -0,0 +1,100 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { fetchAllStat, hidOf, hostName, indexByHid, rmmToken } from '../client'; +import type { RmmRecord } from '../types'; + +export const infrastructureInventoryCheck: IntegrationCheck = { + id: 'infrastructure-inventory', + name: 'MSP360 RMM infrastructure inventory', + description: + 'Join host + hardware + software fleet stats by hid. Inventory refreshes about hourly; polling faster than that will not yield newer data.', + service: 'inventory', + taskMapping: TASK_TEMPLATES.infrastructureInventory, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 RMM infrastructure-inventory check'); + if (!rmmToken(ctx)) { + ctx.fail({ + title: 'Missing MSP360 RMM API token', + description: 'Inventory evidence needs the RMM Bearer token.', + resourceType: 'connection', + resourceId: 'msp360-rmm', + severity: 'high', + remediation: 'Use Settings → General → RMM API tokens. Do not mix Backup Provider Login into this connection.', + }); + return; + } + + let hosts: RmmRecord[]; + let hardware: RmmRecord[] = []; + let software: RmmRecord[] = []; + try { + hosts = await fetchAllStat(ctx, 'host'); + hardware = await fetchAllStat(ctx, 'hardware'); + software = await fetchAllStat(ctx, 'software'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'Failed to fetch MSP360 RMM inventory', + description: 'Host/hardware/software fleet endpoints failed.', + resourceType: 'connection', + resourceId: 'msp360-rmm-inventory', + severity: 'high', + remediation: 'Confirm the RMM token can read computer stats.', + evidence: { error: message }, + }); + return; + } + + if (hosts.length === 0) { + ctx.fail({ + title: 'MSP360 RMM host list is empty', + description: 'Infrastructure inventory cannot be built without hosts.', + resourceType: 'connection', + resourceId: 'msp360-rmm-inventory', + severity: 'medium', + remediation: 'Confirm agents exist in this RMM tenant.', + }); + return; + } + + const hwByHid = indexByHid(hardware); + const swByHid = indexByHid(software); + const checkedAt = new Date().toISOString(); + + ctx.pass({ + title: 'MSP360 RMM inventory snapshot', + description: `Joined ${hosts.length} host(s) with hardware and software rows keyed by hid.`, + resourceType: 'service', + resourceId: 'msp360-rmm-inventory-summary', + evidence: { + hostCount: hosts.length, + hardwareRowCount: hardware.length, + softwareRowCount: software.length, + pollHint: 'Inventory data refreshes about hourly', + checkedAt, + }, + }); + + for (const [index, host] of hosts.entries()) { + const hid = hidOf(host, `host-${index}`); + const name = hostName(host, hid); + const hw = hwByHid.get(hid) ?? []; + const sw = swByHid.get(hid) ?? []; + ctx.pass({ + title: `Inventory: ${name}`, + description: `Host joined with ${hw.length} hardware row(s) and ${sw.length} software row(s).`, + resourceType: 'device', + resourceId: hid, + evidence: { + hid, + host, + hardware: hw, + software: sw.slice(0, 200), + softwareTruncated: sw.length > 200, + checkedAt, + }, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-rmm/checks/monitoring-alerting.ts b/packages/integration-platform/src/manifests/msp360-rmm/checks/monitoring-alerting.ts new file mode 100644 index 0000000000..c2c52f1410 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/checks/monitoring-alerting.ts @@ -0,0 +1,102 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { fetchAllStat, hidOf, hostName, rmmToken } from '../client'; +import type { RmmRecord } from '../types'; + +function alertsOf(row: RmmRecord): unknown { + return ( + row.alerts ?? + row.Alerts ?? + row.activeAlerts ?? + row.ActiveAlerts ?? + row.recentAlerts ?? + row.RecentAlerts ?? + null + ); +} + +export const monitoringAlertingCheck: IntegrationCheck = { + id: 'monitoring-alerting', + name: 'MSP360 RMM monitoring and alerting', + description: + 'Fleet summary stats (alerts refresh ~every 10 minutes). Open alerts are attached as evidence; they do not automatically fail this task.', + service: 'monitoring', + taskMapping: TASK_TEMPLATES.monitoringAlerting, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 RMM monitoring-alerting check'); + if (!rmmToken(ctx)) { + ctx.fail({ + title: 'Missing MSP360 RMM API token', + description: 'Monitoring evidence needs the RMM Bearer token.', + resourceType: 'connection', + resourceId: 'msp360-rmm', + severity: 'high', + remediation: 'Add an RMM API token for a licensed administrator.', + }); + return; + } + + let summaries: RmmRecord[]; + try { + summaries = await fetchAllStat(ctx, 'summary'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'MSP360 RMM summary endpoint failed', + description: 'GET computers/stat/summary/latest was empty or unauthorized — no monitoring evidence.', + resourceType: 'connection', + resourceId: 'msp360-rmm-summary', + severity: 'high', + remediation: 'Confirm the token can read summary stats. Community Edition has no API.', + evidence: { error: message }, + }); + return; + } + + if (summaries.length === 0) { + ctx.fail({ + title: 'MSP360 RMM summary returned no rows', + description: 'No fleet summary/alerts payload. Treated as monitoring not configured for this connection.', + resourceType: 'connection', + resourceId: 'msp360-rmm-summary', + severity: 'medium', + remediation: 'Ensure RMM agents are reporting and the token is not scoped to an empty company.', + }); + return; + } + + const checkedAt = new Date().toISOString(); + ctx.pass({ + title: 'MSP360 RMM fleet summary reachable', + description: `Summary endpoint returned ${summaries.length} row(s). Open alerts are evidence, not an automatic fail of this compliance task.`, + resourceType: 'service', + resourceId: 'msp360-rmm-monitoring', + evidence: { rowCount: summaries.length, checkedAt }, + }); + + for (const [index, row] of summaries.entries()) { + const hid = hidOf(row, `summary-${index}`); + const name = hostName(row, hid); + ctx.pass({ + title: `RMM summary: ${name}`, + description: 'Current summary/alerts snapshot from MSP360 RMM.', + resourceType: 'device', + resourceId: hid, + evidence: { + hid, + name, + alerts: alertsOf(row), + antivirus: row.antivirus ?? row.Antivirus ?? row.avStatus, + patches: row.patches ?? row.Patches ?? row.updateStatus, + smart: row.smart ?? row.SMART ?? row.hddSmart, + cpu: row.cpu ?? row.CpuUsage, + memory: row.memory ?? row.MemoryUsage, + disk: row.disk ?? row.DiskUsage, + raw: row, + checkedAt, + }, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-rmm/checks/secure-devices.ts b/packages/integration-platform/src/manifests/msp360-rmm/checks/secure-devices.ts new file mode 100644 index 0000000000..544a65df36 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/checks/secure-devices.ts @@ -0,0 +1,270 @@ +import { TASK_TEMPLATES } from '../../../task-mappings'; +import type { CheckContext, IntegrationCheck } from '../../../types'; +import { fetchAllStat, hidOf, hostName, indexByHid, pickString, rmmToken, truthyFlag } from '../client'; +import type { RmmRecord } from '../types'; + +const AV_ENABLED_KEYS = [ + 'enabled', + 'Enabled', + 'isEnabled', + 'active', + 'Active', + 'realTimeProtection', + 'RealTimeProtection', + 'isActive', + 'status', + 'Status', + 'state', + 'State', + 'productStatus', +]; + +const ENCRYPTION_KEYS = [ + 'bitlocker', + 'BitLocker', + 'filevault', + 'FileVault', + 'encryption', + 'Encryption', + 'diskEncryption', + 'volumeEncryption', + 'isEncrypted', + 'encrypted', +]; + +const SCREEN_LOCK_KEYS = ['screenLock', 'ScreenLock', 'lockScreen', 'screensaver', 'screenSaver']; + +const UNIX_OS_RE = /\b(linux|debian|ubuntu|unix|freebsd|centos|rhel|fedora)\b/i; +const WINDOWS_OS_RE = /\b(windows|winnt|win32)\b/i; + +function isUnixHost(host: RmmRecord): boolean { + const blob = [ + host.osName, + host.osType, + host.operationSystemID, + host.platformID, + host.os, + host.OS, + ] + .filter((value) => typeof value === 'string') + .join(' '); + if (WINDOWS_OS_RE.test(blob) && !UNIX_OS_RE.test(blob)) { + return false; + } + return UNIX_OS_RE.test(blob); +} + +function antivirusMetric(row: RmmRecord): RmmRecord | null { + const nested = row.antivirus; + return nested && typeof nested === 'object' && !Array.isArray(nested) ? (nested as RmmRecord) : null; +} + +function antivirusActive(rows: RmmRecord[]): { active: boolean; reason: string; sample: RmmRecord | null } { + if (rows.length === 0) { + return { active: false, reason: 'No antivirus inventory row for this hid', sample: null }; + } + for (const row of rows) { + const flag = truthyFlag(row, AV_ENABLED_KEYS); + if (flag === true) { + return { active: true, reason: 'Antivirus reported enabled/active', sample: row }; + } + if (flag === false) { + continue; + } + const nested = antivirusMetric(row); + if (nested) { + const nestedFlag = truthyFlag(nested, AV_ENABLED_KEYS); + if (nestedFlag === true) { + return { active: true, reason: 'Summary antivirus metric reported OK/enabled', sample: row }; + } + } + const product = pickString(row, ['productName', 'ProductName', 'displayName']); + if (product) { + return { active: true, reason: `Antivirus product present (${product}); explicit enabled flag not set`, sample: row }; + } + } + const disabled = rows.some((row) => truthyFlag(row, AV_ENABLED_KEYS) === false); + return { + active: false, + reason: disabled ? 'Antivirus present but disabled' : 'Could not determine an active antivirus product', + sample: rows[0] ?? null, + }; +} + +function firstPresent(rows: RmmRecord[], keys: string[]): unknown { + for (const row of rows) { + for (const key of keys) { + if (key in row && row[key] != null && row[key] !== '') { + return row[key]; + } + } + } + return undefined; +} + +export const secureDevicesCheck: IntegrationCheck = { + id: 'secure-devices', + name: 'MSP360 RMM secure devices', + description: + 'Antivirus from RMM antivirus/summary stats. BitLocker/FileVault and screen lock are marked unverified unless the API actually returns those fields.', + service: 'security', + taskMapping: TASK_TEMPLATES.secureDevices, + + run: async (ctx: CheckContext) => { + ctx.log('Starting MSP360 RMM secure-devices check'); + if (!rmmToken(ctx)) { + ctx.fail({ + title: 'Missing MSP360 RMM API token', + description: 'Secure-devices evidence needs the RMM Bearer token.', + resourceType: 'connection', + resourceId: 'msp360-rmm', + severity: 'high', + remediation: 'Paste an RMM API token (not Backup Provider Login) and reconnect.', + }); + return; + } + + let hosts: RmmRecord[]; + let avRows: RmmRecord[] = []; + let summaries: RmmRecord[] = []; + try { + hosts = await fetchAllStat(ctx, 'host'); + avRows = await fetchAllStat(ctx, 'antivirus'); + summaries = await fetchAllStat(ctx, 'summary'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.fail({ + title: 'Failed to fetch MSP360 RMM security inventory', + description: 'Host/antivirus/summary fleet endpoints failed.', + resourceType: 'connection', + resourceId: 'msp360-rmm-secure', + severity: 'high', + remediation: 'Confirm the RMM token can read computer stats.', + evidence: { error: message }, + }); + return; + } + + if (hosts.length === 0) { + ctx.fail({ + title: 'No RMM hosts to evaluate for secure devices', + description: 'Host inventory was empty.', + resourceType: 'connection', + resourceId: 'msp360-rmm-secure', + severity: 'medium', + remediation: 'Install RMM agents or use a token that can see the fleet.', + }); + return; + } + + const avByHid = indexByHid(avRows); + const summaryByHid = indexByHid(summaries); + const checkedAt = new Date().toISOString(); + let encryptionFieldSeen = false; + let screenLockFieldSeen = false; + + for (const [index, host] of hosts.entries()) { + const hid = hidOf(host, `host-${index}`); + const name = hostName(host, hid); + const avForHost = [...(avByHid.get(hid) ?? []), ...(summaryByHid.get(hid) ?? [])]; + const av = antivirusActive(avForHost); + const encryption = firstPresent(avForHost, ENCRYPTION_KEYS) ?? firstPresent([host], ENCRYPTION_KEYS); + const screenLock = firstPresent(avForHost, SCREEN_LOCK_KEYS) ?? firstPresent([host], SCREEN_LOCK_KEYS); + if (encryption !== undefined) encryptionFieldSeen = true; + if (screenLock !== undefined) screenLockFieldSeen = true; + + const encryptionOff = encryption !== undefined && truthyFlag({ v: encryption }, ['v']) === false; + const screenLockOff = screenLock !== undefined && truthyFlag({ v: screenLock }, ['v']) === false; + + const evidence = { + hid, + name, + antivirusActive: av.active, + antivirusReason: av.reason, + antivirusSample: av.sample, + encryptionField: encryption ?? null, + screenLockField: screenLock ?? null, + checkedAt, + }; + + // Field present and off → fail before AV N/A, so Linux does not skip an explicit encryption-off. + if (encryptionOff) { + ctx.fail({ + title: `Disk encryption reported off: ${name}`, + description: 'RMM returned an encryption field that is not enabled.', + resourceType: 'device', + resourceId: hid, + severity: 'high', + remediation: + 'Enable BitLocker or FileVault on the device. Comp AI Device Agent is still required for laptop encryption evidence if RMM does not cover it.', + evidence, + }); + continue; + } + + if (screenLockOff) { + ctx.fail({ + title: `Screen lock reported off: ${name}`, + description: 'RMM returned a screen-lock field that is not enabled.', + resourceType: 'device', + resourceId: hid, + severity: 'medium', + remediation: 'Enable screen lock / screensaver lock on this endpoint, then re-run.', + evidence, + }); + continue; + } + + if (!av.active) { + if (isUnixHost(host)) { + ctx.pass({ + title: `Antivirus not applicable on Unix/Linux: ${name}`, + description: + 'This host looks like Linux/Unix. MSP360 RMM antivirus inventory is a Windows-oriented control here, so missing AV is not scored as a fail.', + resourceType: 'device', + resourceId: hid, + evidence: { ...evidence, outcome: 'av-not-applicable-unix' }, + }); + continue; + } + ctx.fail({ + title: `Antivirus missing or disabled: ${name}`, + description: av.reason, + resourceType: 'device', + resourceId: hid, + severity: 'high', + remediation: 'Install or enable antivirus on this endpoint via MSP360 RMM, then re-run.', + evidence, + }); + continue; + } + + ctx.pass({ + title: `Antivirus active: ${name}`, + description: + encryption === undefined && screenLock === undefined + ? `${av.reason}. Disk encryption and screen lock were not present on this RMM record (not treated as a pass for those controls).` + : av.reason, + resourceType: 'device', + resourceId: hid, + evidence, + }); + } + + if (!encryptionFieldSeen || !screenLockFieldSeen) { + ctx.pass({ + title: 'RMM cannot fully verify encryption / screen lock', + description: + 'Comp AI secure-devices text asks for BitLocker/FileVault and screen lock. MSP360 RMM fleet stats did not expose those fields on this connection. This is an honest gap — not a fake pass. Use Comp AI Device Agent for laptop encryption evidence.', + resourceType: 'control', + resourceId: 'msp360-rmm-unverified-encryption-screenlock', + evidence: { + encryptionFieldSeen, + screenLockFieldSeen, + note: 'Do not treat this row as proof that disks are encrypted or that screen lock is enforced.', + checkedAt, + }, + }); + } + }, +}; diff --git a/packages/integration-platform/src/manifests/msp360-rmm/client.ts b/packages/integration-platform/src/manifests/msp360-rmm/client.ts new file mode 100644 index 0000000000..f892fe638c --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/client.ts @@ -0,0 +1,301 @@ +import type { CheckContext } from '../../types'; +import { + DEFAULT_RMM_API_BASE_URL, + HOST_NAME_KEYS, + MAX_STAT_PAGES, + STAT_PAGE_SIZE, + STAT_PATHS, + type RmmPage, + type RmmRecord, +} from './types'; + +export function credString(ctx: CheckContext, key: string, fallback = ''): string { + const value = ctx.credentials[key]; + if (Array.isArray(value)) { + return String(value[0] ?? fallback); + } + if (value == null || value === '') { + return fallback; + } + return String(value); +} + +export function rmmBaseUrl(ctx: CheckContext): string { + return credString(ctx, 'baseUrl', DEFAULT_RMM_API_BASE_URL).replace(/\/$/, ''); +} + +export function rmmToken(ctx: CheckContext): string { + return credString(ctx, 'api_key') || credString(ctx, 'token') || credString(ctx, 'apiKey'); +} + +function isRecord(value: unknown): value is RmmRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** Live RMM hid values mix `{GUID}` and bare GUIDs; joins must ignore braces/case. */ +export function normalizeHid(raw: string): string { + return raw.replace(/[{}]/g, '').trim().toLowerCase(); +} + +export function hidOf(row: RmmRecord, fallback = ''): string { + const header = isRecord(row.header) ? row.header : null; + const candidates = [ + row.hid, + row.Hid, + row.HID, + row.computerHid, + row.ComputerHid, + header?.hid, + header?.Hid, + header?.HID, + header?.computerHid, + ]; + for (const candidate of candidates) { + if (candidate != null && String(candidate).trim()) { + return normalizeHid(String(candidate)); + } + } + return fallback ? normalizeHid(fallback) : fallback; +} + +/** + * Fleet stat endpoints wrap each computer as `{ header, data: [...] }`. + * Flatten plugin `data` rows and stamp the header hid so later joins work. + */ +export function expandStatRows(rows: RmmRecord[]): RmmRecord[] { + const out: RmmRecord[] = []; + for (const row of rows) { + const header = isRecord(row.header) ? row.header : {}; + const hid = hidOf(row) || hidOf(header); + const inner = Array.isArray(row.data) ? row.data.filter(isRecord) : []; + const networkFrom = (record: RmmRecord): RmmRecord | null => + isRecord(record.network) ? record.network : null; + + if (inner.length === 0) { + const network = networkFrom(row) ?? networkFrom(header); + out.push({ + ...header, + ...row, + hid, + ip: row.ip ?? network?.ip4Address ?? network?.ip4Address ?? network?.ipAddress, + mac: row.mac ?? network?.macAddress, + }); + continue; + } + + for (const item of inner) { + const network = networkFrom(item) ?? networkFrom(header); + out.push({ + ...header, + ...item, + hid: hid || hidOf(item), + computerName: item.computerName ?? header.computerName ?? row.computerName, + ip: item.ip ?? network?.ip4Address ?? network?.ip4Address ?? network?.ipAddress, + mac: item.mac ?? network?.macAddress, + }); + } + } + return out; +} + +export function pageRows(payload: unknown): { rows: T[]; total: number | null } { + if (Array.isArray(payload)) { + // Bare arrays do not include a fleet total. Using length here would stop pagination + // after a full first page (length === pageSize). + return { rows: payload as T[], total: null }; + } + if (payload && typeof payload === 'object') { + const record = payload as RmmPage; + const rows = ( + (Array.isArray(record.items) ? record.items : null) ?? + (Array.isArray(record.data) ? record.data : null) ?? + (Array.isArray(record.results) ? record.results : null) ?? + [] + ) as T[]; + const total = + typeof record.total === 'number' + ? record.total + : typeof record.Total === 'number' + ? record.Total + : null; + return { rows, total }; + } + return { rows: [], total: null }; +} + +async function fetchStatPage( + ctx: CheckContext, + path: string, + baseUrl: string, + pageNumber: number, + pageSize: number, +): Promise { + return ctx.fetch(path, { + baseUrl, + params: { + pageNumber: String(pageNumber), + pageSize: String(pageSize), + page: String(pageNumber), + take: String(pageSize), + }, + }); +} + +function pageFingerprint(rows: T[]): string { + if (rows.length === 0) { + return ''; + } + const first = rows[0]; + const last = rows[rows.length - 1]; + return `${hidOf(first)}:${hidOf(last)}:${rows.length}`; +} + +function reportTruncation( + ctx: CheckContext, + type: string, + collected: number, + maxPages: number, +): void { + const hostCap = maxPages * STAT_PAGE_SIZE; + ctx.warn(`MSP360 RMM ${type} inventory truncated after ${maxPages} pages`, { + collected, + pageSize: STAT_PAGE_SIZE, + pageCap: maxPages, + }); + ctx.fail({ + title: `MSP360 RMM ${type} inventory truncated`, + description: `Stopped after ${maxPages} pages of ${STAT_PAGE_SIZE}. Remaining hosts were not collected. Evidence below is a partial fleet.`, + resourceType: 'connection', + resourceId: `msp360-rmm-${type}-truncated`, + severity: 'medium', + remediation: `Narrow the RMM token scope or ask Comp AI to raise the page cap if this tenant is larger than ${hostCap} hosts.`, + evidence: { + collected, + pageSize: STAT_PAGE_SIZE, + pageCap: maxPages, + truncated: true, + }, + }); +} + +/** + * Page through a fleet-wide stat endpoint. + * Bare arrays have no total — keep paging until a short page, a repeated page, or the cap. + */ +export async function fetchAllStat( + ctx: CheckContext, + type: keyof typeof STAT_PATHS, + options?: { maxPages?: number }, +): Promise { + const baseUrl = rmmBaseUrl(ctx); + const path = STAT_PATHS[type]; + const maxPages = options?.maxPages ?? MAX_STAT_PAGES; + const all: T[] = []; + let lastPageFull = false; + let previousFingerprint = ''; + + for (let pageNumber = 1; pageNumber <= maxPages; pageNumber += 1) { + const payload = await fetchStatPage(ctx, path, baseUrl, pageNumber, STAT_PAGE_SIZE); + const { rows, total } = pageRows(payload); + if (rows.length === 0) { + lastPageFull = false; + break; + } + + const fingerprint = pageFingerprint(rows); + if (pageNumber > 1 && fingerprint && fingerprint === previousFingerprint) { + ctx.warn('MSP360 RMM page repeated; treating as complete (API likely ignored paging)', { + path, + pageNumber, + }); + lastPageFull = false; + break; + } + previousFingerprint = fingerprint; + + all.push(...rows); + + // Unpaged dump larger than one page — do not request page 2 of the same blob. + if (Array.isArray(payload) && rows.length > STAT_PAGE_SIZE) { + lastPageFull = false; + break; + } + if (total != null && all.length >= total) { + lastPageFull = false; + break; + } + if (rows.length < STAT_PAGE_SIZE) { + lastPageFull = false; + break; + } + lastPageFull = true; + } + + // A full last page at the cap might still be the entire fleet (exactly N * pageSize). + // Probe one more page before calling that truncation. + if (lastPageFull) { + const extraPage = maxPages + 1; + const extraPayload = await fetchStatPage(ctx, path, baseUrl, extraPage, STAT_PAGE_SIZE); + const { rows: extraRows } = pageRows(extraPayload); + const extraFingerprint = pageFingerprint(extraRows); + if (extraRows.length > 0 && extraFingerprint !== previousFingerprint) { + all.push(...extraRows); + reportTruncation(ctx, String(type), all.length, maxPages); + } + } + + return expandStatRows(all) as T[]; +} + +export function indexByHid(rows: RmmRecord[]): Map { + const map = new Map(); + for (const row of rows) { + const hid = hidOf(row); + if (!hid) { + continue; + } + const list = map.get(hid) ?? []; + list.push(row); + map.set(hid, list); + } + return map; +} + +export function truthyFlag(row: RmmRecord, keys: string[]): boolean | null { + for (const key of keys) { + if (!(key in row)) { + continue; + } + const value = row[key]; + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value !== 0; + } + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (['true', 'enabled', 'active', 'running', 'ok', 'protected', '1', 'yes'].includes(v)) { + return true; + } + if (['false', 'disabled', 'inactive', 'stopped', 'off', '0', 'no', 'missing'].includes(v)) { + return false; + } + } + } + return null; +} + +export function pickString(row: RmmRecord, keys: string[]): string | undefined { + for (const key of keys) { + const value = row[key]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + return undefined; +} + +export function hostName(row: RmmRecord, fallback: string): string { + return pickString(row, HOST_NAME_KEYS) ?? fallback; +} diff --git a/packages/integration-platform/src/manifests/msp360-rmm/index.ts b/packages/integration-platform/src/manifests/msp360-rmm/index.ts new file mode 100644 index 0000000000..39e04cba26 --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/index.ts @@ -0,0 +1,78 @@ +import type { IntegrationManifest } from '../../types'; +import { + deviceListCheck, + infrastructureInventoryCheck, + monitoringAlertingCheck, + secureDevicesCheck, +} from './checks'; + +export const msp360RmmManifest: IntegrationManifest = { + id: 'msp360-rmm', + name: 'MSP360 RMM', + description: + 'Collect RMM fleet evidence: device list, antivirus, monitoring/alerts, and hardware/software inventory. Uses an RMM API Bearer token — not Backup Provider Login.', + category: 'Infrastructure', + logoUrl: 'https://images.msp360.com/bimi/msp360-logo.svg', + docsUrl: 'https://help.mspbackups.com/mbs-api-specification/rmm-api/get-started-rmm-api', + isActive: true, + supportsMultipleConnections: false, + + baseUrl: 'https://api.rmm.mspbackups.com', + defaultHeaders: { + Accept: 'application/json', + }, + + auth: { + type: 'api_key', + config: { in: 'header', name: 'Authorization', prefix: 'Bearer ' }, + }, + credentialFields: [ + { + id: 'api_key', + label: 'RMM API token', + type: 'password', + required: true, + helpText: + 'Management Console → Settings → General → RMM API tokens. The admin must have an RMM license. Community Edition has no API. Do not paste Backup Provider Login here.', + }, + { + id: 'baseUrl', + label: 'RMM API base URL', + type: 'url', + required: false, + placeholder: 'https://api.rmm.mspbackups.com', + helpText: 'Default https://api.rmm.mspbackups.com. Some tenants use a regional host shown in Swagger.', + }, + ], + + capabilities: ['checks'], + + services: [ + { + id: 'inventory', + name: 'Fleet inventory', + description: 'Host, hardware, and software stats', + enabledByDefault: true, + implemented: true, + }, + { + id: 'security', + name: 'Endpoint security', + description: 'Antivirus / summary stats', + enabledByDefault: true, + implemented: true, + }, + { + id: 'monitoring', + name: 'Monitoring', + description: 'Summary alerts (~10 minute refresh)', + enabledByDefault: true, + implemented: true, + }, + ], + + checks: [deviceListCheck, secureDevicesCheck, monitoringAlertingCheck, infrastructureInventoryCheck], +}; + +export default msp360RmmManifest; +export * from './types'; diff --git a/packages/integration-platform/src/manifests/msp360-rmm/types.ts b/packages/integration-platform/src/manifests/msp360-rmm/types.ts new file mode 100644 index 0000000000..373a0d98df --- /dev/null +++ b/packages/integration-platform/src/manifests/msp360-rmm/types.ts @@ -0,0 +1,25 @@ +export const DEFAULT_RMM_API_BASE_URL = 'https://api.rmm.mspbackups.com'; + +export const STAT_PAGE_SIZE = 100; +export const MAX_STAT_PAGES = 100; + +/** Current RMM fleet-stat paths. No documented alternate path — do not duplicate these as a fake fallback. */ +export const STAT_PATHS: Record = { + host: '/api/v1/computers/stat/host/latest', + antivirus: '/api/v1/computers/stat/antivirus/latest', + summary: '/api/v1/computers/stat/summary/latest', + hardware: '/api/v1/computers/stat/hardware/latest', + software: '/api/v1/computers/stat/software/latest', +}; + +export const HOST_NAME_KEYS = ['computerName', 'ComputerName', 'name', 'hostName', 'HostName']; + +export interface RmmPage { + data?: T[]; + items?: T[]; + results?: T[]; + total?: number; + Total?: number; +} + +export type RmmRecord = Record; diff --git a/packages/integration-platform/src/registry/index.ts b/packages/integration-platform/src/registry/index.ts index 314ab3beae..f3794c42af 100644 --- a/packages/integration-platform/src/registry/index.ts +++ b/packages/integration-platform/src/registry/index.ts @@ -14,6 +14,8 @@ import { gcpManifest } from '../manifests/gcp'; import { manifest as githubManifest } from '../manifests/github'; import { githubAppManifest } from '../manifests/github-app'; import { googleWorkspaceManifest } from '../manifests/google-workspace'; +import { msp360BackupManifest } from '../manifests/msp360-backup'; +import { msp360RmmManifest } from '../manifests/msp360-rmm'; import { ripplingManifest } from '../manifests/rippling'; import { vercelManifest } from '../manifests/vercel'; @@ -148,6 +150,8 @@ const allManifests: IntegrationManifest[] = [ githubManifest, githubAppManifest, googleWorkspaceManifest, + msp360BackupManifest, + msp360RmmManifest, ripplingManifest, vercelManifest, aikidoManifest,