diff --git a/messages/de.json b/messages/de.json index 518d086..d3157a0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -252,10 +252,11 @@ "linkMeasure": "Maßnahme verknüpfen", "newMeasure": "Neue Maßnahme anlegen & verknüpfen", "measureTitle": "Titel der Maßnahme", - "reductionL": "Minderung Wahrscheinlichkeit (0–4)", - "reductionI": "Minderung Schaden (0–4)", + "reductionL": "Minderung Wahrscheinlichkeit (0,00–4,00)", + "reductionI": "Minderung Schaden (0,00–4,00)", "reduction": "Minderung", - "createFromAsset": "Risiko erstellen" + "createFromAsset": "Risiko erstellen", + "catalogHint": "Aus Katalog wählen oder frei eingeben …" }, "riskTreatment": { "AVOID": "Vermeiden", diff --git a/messages/en.json b/messages/en.json index 3c486d9..9d08def 100644 --- a/messages/en.json +++ b/messages/en.json @@ -252,10 +252,11 @@ "linkMeasure": "Link measure", "newMeasure": "Create & link new measure", "measureTitle": "Measure title", - "reductionL": "Reduction likelihood (0–4)", - "reductionI": "Reduction damage (0–4)", + "reductionL": "Reduction likelihood (0.00–4.00)", + "reductionI": "Reduction damage (0.00–4.00)", "reduction": "Reduction", - "createFromAsset": "Create risk" + "createFromAsset": "Create risk", + "catalogHint": "Pick from catalog or type freely …" }, "riskTreatment": { "AVOID": "Avoid", diff --git a/prisma/migrations/20260702144829_decimal_reductions_and_catalogs/migration.sql b/prisma/migrations/20260702144829_decimal_reductions_and_catalogs/migration.sql new file mode 100644 index 0000000..0480c08 --- /dev/null +++ b/prisma/migrations/20260702144829_decimal_reductions_and_catalogs/migration.sql @@ -0,0 +1,32 @@ +-- AlterTable +ALTER TABLE "risk_measures" ALTER COLUMN "reduction_likelihood" SET DEFAULT 0, +ALTER COLUMN "reduction_likelihood" SET DATA TYPE DOUBLE PRECISION, +ALTER COLUMN "reduction_impact" SET DEFAULT 0, +ALTER COLUMN "reduction_impact" SET DATA TYPE DOUBLE PRECISION; + +-- AlterTable +ALTER TABLE "risks" ALTER COLUMN "residual_likelihood" SET DATA TYPE DOUBLE PRECISION, +ALTER COLUMN "residual_impact" SET DATA TYPE DOUBLE PRECISION, +ALTER COLUMN "residual_score" SET DATA TYPE DOUBLE PRECISION; + +-- CreateTable +CREATE TABLE "threats" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "threats_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "vulnerabilities" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "vulnerabilities_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "threats_name_key" ON "threats"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "vulnerabilities_name_key" ON "vulnerabilities"("name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a73f817..d432c8b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -279,10 +279,10 @@ model Risk { impact Int @default(1) score Int @default(1) // redundant für Listen/Heatmap - // Rest-Risiko nach Maßnahmen - residualLikelihood Int? @map("residual_likelihood") - residualImpact Int? @map("residual_impact") - residualScore Int? @map("residual_score") + // Rest-Risiko nach Maßnahmen (berechnet, dezimal wegen Teil-Minderungen) + residualLikelihood Float? @map("residual_likelihood") + residualImpact Float? @map("residual_impact") + residualScore Float? @map("residual_score") treatment RiskTreatment @default(MITIGATE) status RiskStatus @default(OPEN) @@ -366,9 +366,10 @@ model RiskMeasure { riskId String @map("risk_id") measureId String @map("measure_id") - // Minderung in Stufen (0–4) je Dimension - reductionLikelihood Int @default(0) @map("reduction_likelihood") - reductionImpact Int @default(0) @map("reduction_impact") + // Minderung je Dimension (0,00–4,00) — mehrere Maßnahmen summieren sich, + // bis ein voller Punkt (oder mehr) erreicht ist + reductionLikelihood Float @default(0) @map("reduction_likelihood") + reductionImpact Float @default(0) @map("reduction_impact") risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade) measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade) @@ -378,6 +379,23 @@ model RiskMeasure { @@map("risk_measures") } +// Globale Kataloge (SPEC §5: Threat/Vulnerability, mandantenübergreifend). +// Risiken speichern weiterhin Freitext — der Katalog liefert Vorschläge (datalist), +// eigene Formulierungen bleiben möglich. +model Threat { + id String @id @default(cuid()) + name String @unique + + @@map("threats") +} + +model Vulnerability { + id String @id @default(cuid()) + name String @unique + + @@map("vulnerabilities") +} + model AuditLog { id String @id @default(cuid()) tenantId String @map("tenant_id") diff --git a/prisma/seed.ts b/prisma/seed.ts index d7e0078..6cee2b5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -15,7 +15,52 @@ const prisma = new PrismaClient({ const DEMO_PASSWORD = "Demo1234!"; +const THREATS = [ + "Schadsoftware / Ransomware", + "Phishing / Social Engineering", + "Innentäter / Missbrauch von Berechtigungen", + "Diebstahl oder Verlust von Geräten", + "Ausfall von IT-Systemen oder Diensten", + "Ausfall eines Dienstleisters / Lieferanten", + "Stromausfall / Infrastrukturausfall", + "Feuer / Wasser / Elementarschäden", + "Unbefugter physischer Zutritt", + "Denial-of-Service-Angriff", + "Datenabfluss / Industriespionage", + "Fehlbedienung durch Mitarbeitende", + "Softwarefehler / fehlerhafte Updates", + "Kompromittierte Zugangsdaten", + "Rechtliche / regulatorische Verstöße", +]; + +const VULNERABILITIES = [ + "Fehlende oder veraltete Backups", + "Fehlende Netzwerksegmentierung", + "Unzureichendes Patch-Management", + "Schwache oder wiederverwendete Passwörter", + "Fehlende Multi-Faktor-Authentifizierung", + "Übermäßige Berechtigungen / fehlendes Least-Privilege", + "Fehlende Awareness / Schulungen", + "Unverschlüsselte Datenträger oder Übertragungen", + "Kein Notfallkonzept / ungetesteter Wiederanlauf", + "Single Point of Failure (Technik oder Person)", + "Unzureichende Protokollierung / Überwachung", + "Veraltete oder nicht mehr unterstützte Software", + "Fehlende Vertrags-/AV-Regelungen mit Dienstleistern", + "Offene, ungenutzte Dienste und Ports", + "Unklare Verantwortlichkeiten", +]; + async function main() { + // 0. Globale Kataloge Bedrohungen/Schwachstellen (Vorschläge, Freitext bleibt möglich) + for (const name of THREATS) { + await prisma.threat.upsert({ where: { name }, update: {}, create: { name } }); + } + for (const name of VULNERABILITIES) { + await prisma.vulnerability.upsert({ where: { name }, update: {}, create: { name } }); + } + console.log(`✔ Kataloge: ${THREATS.length} Bedrohungen, ${VULNERABILITIES.length} Schwachstellen`); + // 1. Global permission catalog for (const key of PERMISSIONS) { await prisma.permission.upsert({ where: { key }, update: {}, create: { key } }); diff --git a/src/app/(app)/risks/page.tsx b/src/app/(app)/risks/page.tsx index d7b2c15..86f448d 100644 --- a/src/app/(app)/risks/page.tsx +++ b/src/app/(app)/risks/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { getTranslations } from "next-intl/server"; import { Plus } from "lucide-react"; import { requireSession } from "@/server/auth"; -import { dbForTenant } from "@/server/db"; +import { dbForTenant, prisma } from "@/server/db"; import { hasPermission, requirePermission } from "@/server/rbac"; import { Button } from "@/components/ui/button"; import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui"; @@ -82,7 +82,7 @@ export default async function RisksPage({ : null; const needsFormData = canWrite && (params.edit || params.new); - const [users, processes] = needsFormData + const [users, processes, threats, vulnerabilities] = needsFormData ? await Promise.all([ db.user.findMany({ where: { status: "ACTIVE" }, @@ -90,8 +90,13 @@ export default async function RisksPage({ orderBy: { name: "asc" }, }), db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }), + // Globale Kataloge (kein Mandanten-Bezug) + prisma.threat.findMany({ orderBy: { name: "asc" } }).then((r) => r.map((x) => x.name)), + prisma.vulnerability + .findMany({ orderBy: { name: "asc" } }) + .then((r) => r.map((x) => x.name)), ]) - : [[], []]; + : [[], [], [], []]; const availableAssets = canWrite && params.edit && modalRisk ? await db.asset.findMany({ @@ -263,11 +268,19 @@ export default async function RisksPage({ processes={processes} availableAssets={availableAssets} availableMeasures={availableMeasures} + threats={threats} + vulnerabilities={vulnerabilities} /> ) : modalRisk ? ( ) : params.new && canWrite ? ( - + ) : null} ); diff --git a/src/components/measure-modals.tsx b/src/components/measure-modals.tsx index 864025c..bdf9613 100644 --- a/src/components/measure-modals.tsx +++ b/src/components/measure-modals.tsx @@ -108,7 +108,8 @@ export async function MeasureDetailModal({ {rm.risk.score} · {tLevel(riskLevel(rm.risk.score))} - {tr("reduction")}: −{rm.reductionLikelihood} / −{rm.reductionImpact} + {tr("reduction")}: −{format.number(rm.reductionLikelihood, { maximumFractionDigits: 2 })} / + −{format.number(rm.reductionImpact, { maximumFractionDigits: 2 })} ))} diff --git a/src/components/risk-modals.tsx b/src/components/risk-modals.tsx index 5b6ebe0..16ff2e8 100644 --- a/src/components/risk-modals.tsx +++ b/src/components/risk-modals.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { getTranslations } from "next-intl/server"; -import { Pencil, Trash2, X } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { Check, Pencil, Trash2, X } from "lucide-react"; import type { Prisma } from "@prisma/client"; import { addRiskAsset, @@ -50,7 +50,6 @@ export type RiskWithDetail = Prisma.RiskGetPayload<{ }>; const SCALE = [1, 2, 3, 4, 5] as const; -const REDUCTIONS = [0, 1, 2, 3, 4] as const; const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const; const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const; @@ -63,10 +62,11 @@ const STATUS_TONE = { async function ScorePill({ score }: { score: number }) { const tLevel = await getTranslations("riskLevel"); + const format = await getFormatter(); const level = riskLevel(score); return ( - {score} · {tLevel(level)} + {format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)} ); } @@ -86,6 +86,8 @@ async function RatingBlock({ hint?: string; }) { const t = await getTranslations("risks"); + const format = await getFormatter(); + const num = (v: number) => format.number(v, { maximumFractionDigits: 2 }); return (
{title} @@ -95,14 +97,14 @@ async function RatingBlock({
{t("likelihoodShort")}
-
{likelihood}
+
{num(likelihood)}
×
{t("damageShort")}
-
{impact}
+
{num(impact)}
=
@@ -265,10 +267,7 @@ export async function RiskDetailModal({ {tMStatus(rm.measure.status)} {(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && ( - - {t("reduction")}: −{rm.reductionLikelihood} {t("likelihoodShort")} / − - {rm.reductionImpact} {t("damageShort")} - + )} ))} @@ -285,12 +284,16 @@ export async function RiskEditModal({ processes, availableAssets, availableMeasures, + threats, + vulnerabilities, }: { risk: RiskWithDetail; users: { id: string; name: string }[]; processes: { id: string; name: string }[]; availableAssets: { id: string; name: string }[]; availableMeasures: { id: string; refNo: number; title: string }[]; + threats: string[]; + vulnerabilities: string[]; }) { const t = await getTranslations("risks"); const tc = await getTranslations("common"); @@ -310,7 +313,14 @@ export async function RiskEditModal({ action={updateRisk.bind(null, risk.id)} className="space-y-4" > - + @@ -357,21 +367,38 @@ export async function RiskEditModal({

{t("measures")}

{t("residualAuto")}

{risk.riskMeasures.length === 0 &&

{tc("none")}

} -
    +
      {risk.riskMeasures.map((rm) => ( -
    • - {measureRef(rm.measure.refNo)} {rm.measure.title} - - (−{rm.reductionLikelihood} / −{rm.reductionImpact}) - -
      - +
    • +
      + {measureRef(rm.measure.refNo)} + {rm.measure.title} + + + +
      + {/* Minderung nachträglich anpassen (upsert über linkMeasureToRisk) */} +
      + +
      + +
      +
    • ))} @@ -390,7 +417,7 @@ export async function RiskEditModal({ ))} - + @@ -403,7 +430,7 @@ export async function RiskEditModal({ >

      {t("newMeasure")}

      - + @@ -423,30 +450,52 @@ export async function RiskEditModal({ ); } -/** Minderungs-Auswahl (Wahrscheinlichkeit/Schaden) für Maßnahmen-Verknüpfungen. */ -async function ReductionSelects({ selectClass }: { selectClass: string }) { +/** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */ +async function ReductionLabel({ l, i }: { l: number; i: number }) { + const t = await getTranslations("risks"); + const format = await getFormatter(); + const num = (v: number) => format.number(v, { maximumFractionDigits: 2 }); + return ( + + {t("reduction")}: −{num(l)} {t("likelihoodShort")} / −{num(i)} {t("damageShort")} + + ); +} + +/** Minderungs-Eingaben (dezimal, 0,00–4,00) für Maßnahmen-Verknüpfungen. */ +async function ReductionInputs({ + defaultL = 0, + defaultI = 0, +}: { + defaultL?: number; + defaultI?: number; +}) { const t = await getTranslations("risks"); return (
      - +
      - +
      ); @@ -457,11 +506,15 @@ async function RiskFields({ risk, users, processes, + threats, + vulnerabilities, selectClass, }: { risk?: RiskWithDetail; users: { id: string; name: string }[]; processes: { id: string; name: string }[]; + threats: string[]; + vulnerabilities: string[]; selectClass: string; }) { const t = await getTranslations("risks"); @@ -488,16 +541,35 @@ async function RiskFields({
      - + + + {threats.map((name) => ( +
      + + {vulnerabilities.map((name) => ( +
      @@ -601,10 +673,14 @@ export async function RiskCreateModal({ users, processes, preselectedAsset, + threats, + vulnerabilities, }: { users: { id: string; name: string }[]; processes: { id: string; name: string }[]; preselectedAsset?: { id: string; name: string } | null; + threats: string[]; + vulnerabilities: string[]; }) { const t = await getTranslations("risks"); const tc = await getTranslations("common"); @@ -619,7 +695,13 @@ export async function RiskCreateModal({ >
      {preselectedAsset && } - +