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 / lib / vulnIngest.ts 3021 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
import { z } from "zod";
import { prisma } from "@/lib/prisma";

/**
 * Shared NetscanXi -> Synapse Sonar vulnerability ingest.
 *
 * Used by BOTH integration directions so they behave identically:
 *   - PUSH  app/api/ingest/vulnerabilities  (NetscanXi POSTs to us)
 *   - PULL  app/api/integrations/netscan/sync (we GET from NetscanXi)
 *
 * Assets are upserted by (tenant, IP); vulnerabilities by (asset, CVE).
 */

export const FindingSchema = z.object({
  ip: z.string().ip(),
  hostname: z.string().optional(),
  cveId: z.string().min(3).max(64),
  cvssScore: z.number().min(0).max(10),
  severity: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional(),
  title: z.string().max(500).optional(),
  source: z.string().max(120).optional(),
  discoveredAt: z.string().datetime().optional(),
});

export const PayloadSchema = z.object({
  findings: z.array(FindingSchema).min(1).max(1000),
});

export type Finding = z.infer<typeof FindingSchema>;

export function severityFromScore(
  score: number
): "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" {
  if (score >= 9.0) return "CRITICAL";
  if (score >= 7.0) return "HIGH";
  if (score >= 4.0) return "MEDIUM";
  return "LOW";
}

export interface IngestSummary {
  assetsTouched: number;
  vulnsUpserted: number;
}

/** Upsert a batch of findings for a tenant. Returns the touched counts. */
export async function ingestFindings(
  tenantId: string,
  findings: Finding[]
): Promise<IngestSummary> {
  let assetsTouched = 0;
  let vulnsUpserted = 0;

  for (const f of findings) {
    // Ensure the asset exists for this tenant (create a minimal node if new).
    // IMPORTANT: vulnerability ingestion must NOT modify an existing node's
    // hostname. The hostname is owned by flow discovery (the agent); a scan's
    // hostname value (often absent, an IP, or a scan label) must never overwrite
    // it. We therefore only set hostname when creating a brand-new node.
    const asset = await prisma.asset.upsert({
      where: { tenantId_ipAddress: { tenantId, ipAddress: f.ip } },
      create: {
        tenantId,
        ipAddress: f.ip,
        hostname: f.hostname ?? null,
      },
      update: {}, // never touch hostname (or any asset field) on ingest
      select: { id: true },
    });
    assetsTouched++;

    const severity = f.severity ?? severityFromScore(f.cvssScore);
    const discoveredAt = f.discoveredAt ? new Date(f.discoveredAt) : new Date();

    await prisma.vulnerability.upsert({
      where: { assetId_cveId: { assetId: asset.id, cveId: f.cveId } },
      create: {
        tenantId,
        assetId: asset.id,
        cveId: f.cveId,
        title: f.title ?? null,
        cvssScore: f.cvssScore,
        severity,
        source: f.source ?? "NETSCAN_XI",
        discoveredAt,
      },
      update: {
        title: f.title ?? null,
        cvssScore: f.cvssScore,
        severity,
        source: f.source ?? "NETSCAN_XI",
        discoveredAt,
      },
    });
    vulnsUpserted++;
  }

  return { assetsTouched, vulnsUpserted };
}