Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 99 additions & 1 deletion backend-serverless/src/api/integrations.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { asyncHandler, AppError } from '../middleware/error.middleware.js';
import { validate } from '../middleware/validation.js';
import { IntegrationsSettingsService } from '../services/integrations/settings.js';
import { DefenderSyncService } from '../services/defender/sync.service.js';
import { AzureCredentialsSchema, AzureTestSchema, DefenderCredentialsSchema, DefenderTestSchema, DefenderAutoResolveModeSchema } from '../schemas/integrations.schemas.js';
import { AzureCredentialsSchema, AzureTestSchema, DefenderCredentialsSchema, DefenderTestSchema, DefenderAutoResolveModeSchema, SophosCredentialsSchema, SophosTestSchema } from '../schemas/integrations.schemas.js';
import { SophosCentralClient, SophosApiError } from '../services/sophos/sophos-client.js';
import { DEFENDER_INDEX } from '../services/defender/index-management.js';
import { SettingsService as AnalyticsSettingsService } from '../services/analytics/settings.js';
import { createEsClient } from '../services/analytics/client.js';
Expand Down Expand Up @@ -340,4 +341,101 @@ router.get('/defender/auto-resolve/receipts', requirePermission('integrations:re
}));

export { defenderSyncService };

// ── Sophos Central ──────────────────────────────────────────────────
//
// Sophos differs from Defender in two operator-visible ways: no tenant_id
// input field (discovered via whoami), and the /test route returns the
// discovered tenant + region + tier so the UI can confirm the connection
// metadata before save. See `backend/src/api/integrations.routes.ts` for
// the matching pattern in the Docker backend.

router.get('/sophos', requirePermission('integrations:read'), asyncHandler(async (_req, res) => {
const settings = await settingsService.getSophosSettings();

if (!settings?.configured) {
res.json({ configured: false });
return;
}

const mask = (val: string) =>
val && val.length > 4 ? '****' + val.slice(-4) : '****';

res.json({
configured: true,
client_id: mask(settings.client_id),
client_secret_set: !!settings.client_secret,
label: settings.label ?? '',
tenant_id: settings.tenant_id,
data_region: settings.data_region,
tier: settings.tier,
env_configured: settingsService.isEnvSophosConfigured(),
});
}));

router.post('/sophos', requirePermission('integrations:write'), validate(SophosCredentialsSchema), asyncHandler(async (req, res) => {
const { client_id, client_secret, label } = req.body;

const isEdit = await settingsService.isSophosConfigured();

if (!isEdit) {
if (!client_id || !client_secret) {
throw new AppError('client_id and client_secret are required for initial setup', 400);
}
}

await settingsService.saveSophosSettings({ client_id, client_secret, label });
res.json({ success: true });
}));

router.delete('/sophos', requirePermission('integrations:write'), asyncHandler(async (_req, res) => {
if (settingsService.isEnvSophosConfigured()) {
throw new AppError(
'Cannot disconnect: Sophos credentials are set via environment variables (SOPHOS_CLIENT_ID, SOPHOS_CLIENT_SECRET). Remove these env vars to disconnect.',
400
);
}
await settingsService.deleteSophosSettings();
res.json({ success: true });
}));

router.post('/sophos/test', requirePermission('integrations:write'), validate(SophosTestSchema), asyncHandler(async (req, res) => {
const { client_id, client_secret } = req.body;

const stored = await settingsService.getSophosCredentials();
const effectiveClientId = client_id || stored?.client_id;
const effectiveClientSecret = client_secret || stored?.client_secret;

if (!effectiveClientId || !effectiveClientSecret) {
res.json({
success: false,
error: 'Missing credentials: client_id and client_secret are both required',
});
return;
}

try {
const client = new SophosCentralClient(effectiveClientId, effectiveClientSecret);
const result = await client.testConnection();

res.json({
success: true,
tenant_id: result.tenantId,
data_region: result.dataRegion,
tier: result.tier,
id_type: result.idType,
message: `Connected to Sophos Central tenant ${result.tenantId} (${result.tier} tier)`,
});
} catch (err) {
if (err instanceof SophosApiError) {
res.json({ success: false, error: err.message });
return;
}
res.json({
success: false,
error: `Connection failed: ${err instanceof Error ? err.message : 'Unknown error'}`,
});
}
}));

export default router;
13 changes: 13 additions & 0 deletions backend-serverless/src/schemas/integrations.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ export const DefenderAutoResolveModeSchema = z.object({
mode: z.enum(['disabled', 'dry_run', 'enabled']),
});

// ── Sophos Central ───────────────────────────────────────────────────────────

export const SophosCredentialsSchema = z.object({
client_id: z.string().optional(),
client_secret: z.string().optional(),
label: z.string().optional(),
});

export const SophosTestSchema = z.object({
client_id: z.string().optional(),
client_secret: z.string().optional(),
});

// ── Alert Settings ───────────────────────────────────────────────────────────

const AlertThresholdsSchema = z.object({
Expand Down
94 changes: 93 additions & 1 deletion backend-serverless/src/services/integrations/settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Settings service for external integration credentials — Vercel Blob storage version

import * as crypto from 'crypto';
import type { AzureIntegrationSettings, DefenderIntegrationSettings, IntegrationsSettings } from '../../types/integrations.js';
import type { AzureIntegrationSettings, DefenderIntegrationSettings, SophosIntegrationSettings, IntegrationsSettings } from '../../types/integrations.js';
import type { AutoResolveMode } from '../../types/defender.js';
import { blobReadText, blobWrite } from '../storage.js';

Expand Down Expand Up @@ -89,6 +89,16 @@ export class IntegrationsSettingsService {
settings.defender.client_secret = this.decrypt(settings.defender.client_secret.slice(4));
}
}
// Sophos: only client_id + client_secret are encrypted; tenant_id,
// data_region, tier are discovered via whoami and stored as plaintext.
if (settings.sophos) {
if (settings.sophos.client_id?.startsWith('enc:')) {
settings.sophos.client_id = this.decrypt(settings.sophos.client_id.slice(4));
}
if (settings.sophos.client_secret?.startsWith('enc:')) {
settings.sophos.client_secret = this.decrypt(settings.sophos.client_secret.slice(4));
}
}
return settings;
} catch (error) {
console.error('Error loading integrations settings:', error);
Expand Down Expand Up @@ -283,6 +293,88 @@ export class IntegrationsSettingsService {
};
}

// ── Sophos Central ─────────────────────────────────────────────────
//
// Mirrors the Defender section but with two structural differences:
// 1. Only client_id + client_secret are operator-supplied. tenant_id,
// data_region, and tier are discovered via Sophos's whoami endpoint.
// 2. Env-var override only sets credentials; the discovered fields
// never come from env vars (no SOPHOS_TENANT_ID etc.).

private getEnvSophosSettings(): SophosIntegrationSettings | null {
const clientId = process.env.SOPHOS_CLIENT_ID;
const clientSecret = process.env.SOPHOS_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
return {
client_id: clientId,
client_secret: clientSecret,
configured: true,
label: process.env.SOPHOS_TENANT_LABEL || undefined,
};
}

isEnvSophosConfigured(): boolean {
return this.getEnvSophosSettings() !== null;
}

async getSophosSettings(): Promise<SophosIntegrationSettings | null> {
const fileSettings = await this.getFileSettings();
if (fileSettings?.sophos?.configured) {
return fileSettings.sophos;
}
return this.getEnvSophosSettings();
}

async saveSophosSettings(settings: Partial<SophosIntegrationSettings>): Promise<void> {
const existing = await this.getFileSettings() ?? {};
const current = existing.sophos ?? {
client_id: '',
client_secret: '',
configured: false,
};
const merged: SophosIntegrationSettings = {
client_id: settings.client_id || current.client_id,
client_secret: settings.client_secret || current.client_secret,
configured: true,
label: settings.label !== undefined ? settings.label : current.label,
tenant_id: settings.tenant_id !== undefined ? settings.tenant_id : current.tenant_id,
data_region: settings.data_region !== undefined ? settings.data_region : current.data_region,
tier: settings.tier !== undefined ? settings.tier : current.tier,
last_alert_sync: settings.last_alert_sync !== undefined ? settings.last_alert_sync : current.last_alert_sync,
last_score_sync: settings.last_score_sync !== undefined ? settings.last_score_sync : current.last_score_sync,
auto_resolve_mode: settings.auto_resolve_mode !== undefined ? settings.auto_resolve_mode : current.auto_resolve_mode,
};
const toSave: IntegrationsSettings = {
...existing,
sophos: {
...merged,
client_id: 'enc:' + this.encrypt(merged.client_id),
client_secret: 'enc:' + this.encrypt(merged.client_secret),
},
};
await blobWrite(SETTINGS_KEY, JSON.stringify(toSave, null, 2));
}

async isSophosConfigured(): Promise<boolean> {
const settings = await this.getSophosSettings();
if (!settings?.configured) return false;
return !!(settings.client_id && settings.client_secret);
}

async getSophosCredentials(): Promise<{ client_id: string; client_secret: string } | null> {
const settings = await this.getSophosSettings();
if (!settings?.configured) return null;
if (!settings.client_id || !settings.client_secret) return null;
return { client_id: settings.client_id, client_secret: settings.client_secret };
}

async deleteSophosSettings(): Promise<void> {
const raw = await this.getRawFileSettings();
if (!raw || !raw.sophos) return;
delete raw.sophos;
await blobWrite(SETTINGS_KEY, JSON.stringify(raw, null, 2));
}

// ── Defender Auto-Resolve mode ─────────────────────────────────────

/**
Expand Down
Loading
Loading