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 / netscan / sync / route.ts 3797 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
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireTenantContext, HttpError } from "@/lib/auth";
import { assertCan } from "@/lib/rbac";
import { getEnabledIntegration, NetscanXiConfig } from "@/lib/integrations";
import { FindingSchema, ingestFindings } from "@/lib/vulnIngest";
import { IntegrationProvider } from "@prisma/client";

export const runtime = "nodejs";

/**
 * POST /api/integrations/netscan/sync
 *
 * PULL sync: fetch the latest vulnerability findings from the configured
 * NetscanXi instance and upsert them, so Sonar stays in sync with NetscanXi.
 *
 * Uses the endpoint URL + API key stored on the NetscanXi integration card
 * (the key that ORIGINATES in NetscanXi). Point a scheduler at this route to
 * make the sync continuous/active.
 */

// NetscanXi's feed returns { ok, findings: [...], ... }; we only need the array.
// Each finding is validated individually below so one bad row can't sink a whole
// sync — resilience matters for a continuously-polled feed.
const FeedEnvelopeSchema = z.object({
  findings: z.array(z.unknown()).max(5000).default([]),
});

export async function POST() {
  try {
    const ctx = await requireTenantContext();
    assertCan(ctx.role, "integration:write");

    const cfg = await getEnabledIntegration<NetscanXiConfig>(
      ctx.tenantId,
      IntegrationProvider.NETSCAN_XI
    );
    if (!cfg) {
      return NextResponse.json(
        { error: "NetscanXi integration is disabled or not configured." },
        { status: 400 }
      );
    }
    if (!cfg.endpointUrl || !cfg.apiKey) {
      return NextResponse.json(
        { error: "Set the NetscanXi Endpoint URL and API key first." },
        { status: 400 }
      );
    }

    // Pull from NetscanXi with the key it issued.
    let res: Response;
    try {
      res = await fetch(cfg.endpointUrl, {
        method: "GET",
        headers: {
          Authorization: `Bearer ${cfg.apiKey}`,
          Accept: "application/json",
        },
        cache: "no-store",
      });
    } catch (e) {
      return NextResponse.json(
        { error: `Could not reach NetscanXi: ${(e as Error).message}` },
        { status: 502 }
      );
    }

    if (res.status === 401) {
      return NextResponse.json(
        { error: "NetscanXi rejected the API key (401). Regenerate it in NetscanXi and re-paste." },
        { status: 401 }
      );
    }
    if (!res.ok) {
      return NextResponse.json(
        { error: `NetscanXi returned HTTP ${res.status}.` },
        { status: 502 }
      );
    }

    let json: unknown;
    try {
      json = await res.json();
    } catch {
      return NextResponse.json(
        { error: "NetscanXi response was not valid JSON." },
        { status: 502 }
      );
    }

    const envelope = FeedEnvelopeSchema.safeParse(json);
    if (!envelope.success) {
      return NextResponse.json(
        { error: "Unexpected feed shape from NetscanXi.", details: envelope.error.issues },
        { status: 422 }
      );
    }

    // Keep only well-formed findings; count the rest as skipped.
    const valid: z.infer<typeof FindingSchema>[] = [];
    let skipped = 0;
    for (const raw of envelope.data.findings.slice(0, 1000)) {
      const f = FindingSchema.safeParse(raw);
      if (f.success) valid.push(f.data);
      else skipped++;
    }

    const summary = await ingestFindings(ctx.tenantId, valid);
    return NextResponse.json({
      ok: true,
      pulled: valid.length,
      skipped,
      summary,
    });
  } catch (err) {
    if (err instanceof HttpError) {
      return NextResponse.json({ error: err.message }, { status: err.status });
    }
    const status = (err as { status?: number }).status ?? 500;
    return NextResponse.json({ error: (err as Error).message }, { status });
  }
}