admin / Synapse-Sonar
publicAttack Surface Simulation
Synapse-Sonar / synapse-sonar / app / api / integrations / route.ts
5473 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 | import { NextRequest, NextResponse } from "next/server"; import crypto from "crypto"; import { z } from "zod"; import { requireTenantContext, HttpError } from "@/lib/auth"; import { assertCan } from "@/lib/rbac"; import { prisma } from "@/lib/prisma"; import { encryptJson, decryptJson, maskSecret, EncryptedBlob } from "@/lib/crypto"; import { IntegrationProvider } from "@prisma/client"; export const runtime = "nodejs"; const sha256 = (v: string) => crypto.createHash("sha256").update(v).digest("hex"); /** GET — list both integration cards with masked secrets (never plaintext to client). */ export async function GET() { try { const ctx = await requireTenantContext(); assertCan(ctx.role, "integration:read"); const rows = await prisma.integrationSetting.findMany({ where: { tenantId: ctx.tenantId }, }); const view = (provider: IntegrationProvider) => { const row = rows.find((r) => r.provider === provider); const cfg = row?.config as { ingestKeyHash?: string; vulnKeyHash?: string } | null; const isNetscan = provider === IntegrationProvider.NETSCAN_XI; const hasIngestKey = isNetscan && Boolean(cfg?.ingestKeyHash); // collectors/agents const hasVulnKey = isNetscan && Boolean(cfg?.vulnKeyHash); // vulnerability feed if (!row || !row.config) { return { provider, enabled: row?.enabled ?? false, config: null, hasIngestKey, hasVulnKey }; } const decrypted = decryptJson<Record<string, string>>( row.config as unknown as EncryptedBlob ); // Mask every secret-looking field before returning. const masked: Record<string, string> = {}; for (const [k, v] of Object.entries(decrypted)) { masked[k] = /key|token|secret/i.test(k) ? maskSecret(v) : v; } return { provider, enabled: row.enabled, config: masked, hasIngestKey, hasVulnKey }; }; return NextResponse.json({ integrations: [ view(IntegrationProvider.NETSCAN_XI), view(IntegrationProvider.SYNAPSE_IRONNODE), ], }); } catch (err) { return errorResponse(err); } } const UpsertSchema = z.object({ provider: z.enum(["NETSCAN_XI", "SYNAPSE_IRONNODE"]), enabled: z.boolean(), // Fields are provider-specific; only persisted if non-empty (so masked // values sent back from the UI don't overwrite real secrets). config: z.record(z.string()).optional(), }); /** PATCH — toggle a card on/off and/or update its config. Admin only. */ export async function PATCH(req: NextRequest) { try { const ctx = await requireTenantContext(); assertCan(ctx.role, "integration:write"); const body = UpsertSchema.parse(await req.json()); const provider = body.provider as IntegrationProvider; const existing = await prisma.integrationSetting.findUnique({ where: { tenantId_provider: { tenantId: ctx.tenantId, provider } }, }); // Start from existing decrypted config, overlay only newly-provided, // non-masked fields so we don't clobber stored secrets with bullets. let mergedConfig: Record<string, string> = {}; if (existing?.config) { mergedConfig = decryptJson<Record<string, string>>( existing.config as unknown as EncryptedBlob ); } if (body.config) { for (const [k, v] of Object.entries(body.config)) { if (v && !v.startsWith("••")) mergedConfig[k] = v; } } const blob = encryptJson(mergedConfig) as unknown as Record<string, unknown>; // Preserve BOTH key hashes across a config save. The collector key // (ingestKeyHash) is managed from the Agents tab and must never be touched // here. Auto-issue the vulnerability-feed key (vulnKeyHash) the first time // NetscanXi is enabled so the card has something to show. const prevCfg = existing?.config as | { ingestKeyHash?: string; vulnKeyHash?: string } | null; const prevIngestKeyHash = prevCfg?.ingestKeyHash; let vulnKeyHash = prevCfg?.vulnKeyHash; let freshVulnKey: string | undefined; if (provider === IntegrationProvider.NETSCAN_XI && body.enabled && !vulnKeyHash) { freshVulnKey = `nsx_api_${crypto.randomBytes(24).toString("hex")}`; vulnKeyHash = sha256(freshVulnKey); } const configToStore = { ...blob, ...(prevIngestKeyHash ? { ingestKeyHash: prevIngestKeyHash } : {}), ...(vulnKeyHash ? { vulnKeyHash } : {}), }; await prisma.integrationSetting.upsert({ where: { tenantId_provider: { tenantId: ctx.tenantId, provider } }, create: { tenantId: ctx.tenantId, provider, enabled: body.enabled, config: configToStore as never, }, update: { enabled: body.enabled, config: configToStore as never, }, }); // The plaintext key is returned exactly once — it is only stored hashed. return NextResponse.json({ ok: true, ...(freshVulnKey ? { ingestKey: freshVulnKey } : {}), }); } catch (err) { return errorResponse(err); } } function errorResponse(err: unknown) { if (err instanceof HttpError) { return NextResponse.json({ error: err.message }, { status: err.status }); } if (err instanceof z.ZodError) { return NextResponse.json({ error: "Invalid input", details: err.issues }, { status: 422 }); } const status = (err as { status?: number }).status ?? 500; return NextResponse.json({ error: (err as Error).message }, { status }); } |