Catalyst / admin/Synapse-Sonar 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Synapse-Sonar

public

Attack Surface Simulation

Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
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 });
}