admin / Synapse-Sonar
publicAttack Surface Simulation
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 }); } } |