Risiko-Minderung dezimal + editierbar, Bedrohungs-/Schwachstellen-Kataloge

- Minderung je Maßnahmen-Verknüpfung jetzt dezimal (0,00–4,00 statt
  ganzer Punkte): mehrere Maßnahmen summieren sich, bis eine Dimension
  einen vollen Punkt sinkt; Rest-Risiko entsprechend dezimal (z. B.
  2,5 × 4 = 10), gerundet auf 2 Nachkommastellen, lokalisierte Anzeige
- Minderung bereits verknüpfter Maßnahmen direkt im Risiko-Bearbeiten
  editierbar (Eingabefelder je Verknüpfung + Speichern-Button);
  Komma-Eingaben werden akzeptiert
- Globale Kataloge Bedrohungen/Schwachstellen (je 15 Einträge, Seed):
  Eingabefelder schlagen per datalist Katalogwerte vor, Freitext bleibt
  weiterhin möglich (SPEC §5 Threat/Vulnerability)
- Schema: reduction*/residual* auf Float, Threat-/Vulnerability-Modelle

Verifiziert: −1 + −0,5 Minderung → Rest 2,5 × 4 = 10 · Erhöht (DB +
Anzeige), Neuberechnung auch beim Entfernen einer Verknüpfung.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 16:55:27 +02:00
co-authored by Claude Fable 5
parent 668e587881
commit dfe27de51f
10 changed files with 274 additions and 70 deletions
+4 -3
View File
@@ -252,10 +252,11 @@
"linkMeasure": "Maßnahme verknüpfen", "linkMeasure": "Maßnahme verknüpfen",
"newMeasure": "Neue Maßnahme anlegen & verknüpfen", "newMeasure": "Neue Maßnahme anlegen & verknüpfen",
"measureTitle": "Titel der Maßnahme", "measureTitle": "Titel der Maßnahme",
"reductionL": "Minderung Wahrscheinlichkeit (04)", "reductionL": "Minderung Wahrscheinlichkeit (0,004,00)",
"reductionI": "Minderung Schaden (04)", "reductionI": "Minderung Schaden (0,004,00)",
"reduction": "Minderung", "reduction": "Minderung",
"createFromAsset": "Risiko erstellen" "createFromAsset": "Risiko erstellen",
"catalogHint": "Aus Katalog wählen oder frei eingeben …"
}, },
"riskTreatment": { "riskTreatment": {
"AVOID": "Vermeiden", "AVOID": "Vermeiden",
+4 -3
View File
@@ -252,10 +252,11 @@
"linkMeasure": "Link measure", "linkMeasure": "Link measure",
"newMeasure": "Create & link new measure", "newMeasure": "Create & link new measure",
"measureTitle": "Measure title", "measureTitle": "Measure title",
"reductionL": "Reduction likelihood (04)", "reductionL": "Reduction likelihood (0.004.00)",
"reductionI": "Reduction damage (04)", "reductionI": "Reduction damage (0.004.00)",
"reduction": "Reduction", "reduction": "Reduction",
"createFromAsset": "Create risk" "createFromAsset": "Create risk",
"catalogHint": "Pick from catalog or type freely …"
}, },
"riskTreatment": { "riskTreatment": {
"AVOID": "Avoid", "AVOID": "Avoid",
@@ -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");
+25 -7
View File
@@ -279,10 +279,10 @@ model Risk {
impact Int @default(1) impact Int @default(1)
score Int @default(1) // redundant für Listen/Heatmap score Int @default(1) // redundant für Listen/Heatmap
// Rest-Risiko nach Maßnahmen // Rest-Risiko nach Maßnahmen (berechnet, dezimal wegen Teil-Minderungen)
residualLikelihood Int? @map("residual_likelihood") residualLikelihood Float? @map("residual_likelihood")
residualImpact Int? @map("residual_impact") residualImpact Float? @map("residual_impact")
residualScore Int? @map("residual_score") residualScore Float? @map("residual_score")
treatment RiskTreatment @default(MITIGATE) treatment RiskTreatment @default(MITIGATE)
status RiskStatus @default(OPEN) status RiskStatus @default(OPEN)
@@ -366,9 +366,10 @@ model RiskMeasure {
riskId String @map("risk_id") riskId String @map("risk_id")
measureId String @map("measure_id") measureId String @map("measure_id")
// Minderung in Stufen (04) je Dimension // Minderung je Dimension (0,004,00) — mehrere Maßnahmen summieren sich,
reductionLikelihood Int @default(0) @map("reduction_likelihood") // bis ein voller Punkt (oder mehr) erreicht ist
reductionImpact Int @default(0) @map("reduction_impact") reductionLikelihood Float @default(0) @map("reduction_likelihood")
reductionImpact Float @default(0) @map("reduction_impact")
risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade) risk Risk @relation(fields: [riskId], references: [id], onDelete: Cascade)
measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade) measure Measure @relation(fields: [measureId], references: [id], onDelete: Cascade)
@@ -378,6 +379,23 @@ model RiskMeasure {
@@map("risk_measures") @@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 { model AuditLog {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
+45
View File
@@ -15,7 +15,52 @@ const prisma = new PrismaClient({
const DEMO_PASSWORD = "Demo1234!"; 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() { 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 // 1. Global permission catalog
for (const key of PERMISSIONS) { for (const key of PERMISSIONS) {
await prisma.permission.upsert({ where: { key }, update: {}, create: { key } }); await prisma.permission.upsert({ where: { key }, update: {}, create: { key } });
+17 -4
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { requireSession } from "@/server/auth"; import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant, prisma } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac"; import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui"; import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
@@ -82,7 +82,7 @@ export default async function RisksPage({
: null; : null;
const needsFormData = canWrite && (params.edit || params.new); const needsFormData = canWrite && (params.edit || params.new);
const [users, processes] = needsFormData const [users, processes, threats, vulnerabilities] = needsFormData
? await Promise.all([ ? await Promise.all([
db.user.findMany({ db.user.findMany({
where: { status: "ACTIVE" }, where: { status: "ACTIVE" },
@@ -90,8 +90,13 @@ export default async function RisksPage({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}), }),
db.process.findMany({ select: { id: true, name: true }, 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 = const availableAssets =
canWrite && params.edit && modalRisk canWrite && params.edit && modalRisk
? await db.asset.findMany({ ? await db.asset.findMany({
@@ -263,11 +268,19 @@ export default async function RisksPage({
processes={processes} processes={processes}
availableAssets={availableAssets} availableAssets={availableAssets}
availableMeasures={availableMeasures} availableMeasures={availableMeasures}
threats={threats}
vulnerabilities={vulnerabilities}
/> />
) : modalRisk ? ( ) : modalRisk ? (
<RiskDetailModal risk={modalRisk} canWrite={canWrite} /> <RiskDetailModal risk={modalRisk} canWrite={canWrite} />
) : params.new && canWrite ? ( ) : params.new && canWrite ? (
<RiskCreateModal users={users} processes={processes} preselectedAsset={preselectedAsset} /> <RiskCreateModal
users={users}
processes={processes}
preselectedAsset={preselectedAsset}
threats={threats}
vulnerabilities={vulnerabilities}
/>
) : null} ) : null}
</main> </main>
); );
+2 -1
View File
@@ -108,7 +108,8 @@ export async function MeasureDetailModal({
{rm.risk.score} · {tLevel(riskLevel(rm.risk.score))} {rm.risk.score} · {tLevel(riskLevel(rm.risk.score))}
</Pill> </Pill>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{tr("reduction")}: {rm.reductionLikelihood} / {rm.reductionImpact} {tr("reduction")}: {format.number(rm.reductionLikelihood, { maximumFractionDigits: 2 })} /
{format.number(rm.reductionImpact, { maximumFractionDigits: 2 })}
</span> </span>
</li> </li>
))} ))}
+119 -37
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getFormatter, getTranslations } from "next-intl/server";
import { Pencil, Trash2, X } from "lucide-react"; import { Check, Pencil, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { import {
addRiskAsset, addRiskAsset,
@@ -50,7 +50,6 @@ export type RiskWithDetail = Prisma.RiskGetPayload<{
}>; }>;
const SCALE = [1, 2, 3, 4, 5] as const; 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 TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const; const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
@@ -63,10 +62,11 @@ const STATUS_TONE = {
async function ScorePill({ score }: { score: number }) { async function ScorePill({ score }: { score: number }) {
const tLevel = await getTranslations("riskLevel"); const tLevel = await getTranslations("riskLevel");
const format = await getFormatter();
const level = riskLevel(score); const level = riskLevel(score);
return ( return (
<Pill tone={RISK_PILL_TONE[level]}> <Pill tone={RISK_PILL_TONE[level]}>
{score} · {tLevel(level)} {format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)}
</Pill> </Pill>
); );
} }
@@ -86,6 +86,8 @@ async function RatingBlock({
hint?: string; hint?: string;
}) { }) {
const t = await getTranslations("risks"); const t = await getTranslations("risks");
const format = await getFormatter();
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
return ( return (
<div> <div>
<b>{title}</b> <b>{title}</b>
@@ -95,14 +97,14 @@ async function RatingBlock({
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide"> <div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
{t("likelihoodShort")} {t("likelihoodShort")}
</div> </div>
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{likelihood}</div> <div className="mt-0.5 font-heading text-xl font-bold leading-none">{num(likelihood)}</div>
</div> </div>
<div className="pb-0.5 text-muted-foreground">×</div> <div className="pb-0.5 text-muted-foreground">×</div>
<div> <div>
<div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide"> <div className="text-[10.5px] leading-tight text-muted-foreground uppercase tracking-wide">
{t("damageShort")} {t("damageShort")}
</div> </div>
<div className="mt-0.5 font-heading text-xl font-bold leading-none">{impact}</div> <div className="mt-0.5 font-heading text-xl font-bold leading-none">{num(impact)}</div>
</div> </div>
<div className="pb-0.5 text-muted-foreground">=</div> <div className="pb-0.5 text-muted-foreground">=</div>
<div className="pb-0.5"> <div className="pb-0.5">
@@ -265,10 +267,7 @@ export async function RiskDetailModal({
{tMStatus(rm.measure.status)} {tMStatus(rm.measure.status)}
</Pill> </Pill>
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && ( {(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
<span className="text-xs text-muted-foreground"> <ReductionLabel l={rm.reductionLikelihood} i={rm.reductionImpact} />
{t("reduction")}: {rm.reductionLikelihood} {t("likelihoodShort")} /
{rm.reductionImpact} {t("damageShort")}
</span>
)} )}
</li> </li>
))} ))}
@@ -285,12 +284,16 @@ export async function RiskEditModal({
processes, processes,
availableAssets, availableAssets,
availableMeasures, availableMeasures,
threats,
vulnerabilities,
}: { }: {
risk: RiskWithDetail; risk: RiskWithDetail;
users: { id: string; name: string }[]; users: { id: string; name: string }[];
processes: { id: string; name: string }[]; processes: { id: string; name: string }[];
availableAssets: { id: string; name: string }[]; availableAssets: { id: string; name: string }[];
availableMeasures: { id: string; refNo: number; title: string }[]; availableMeasures: { id: string; refNo: number; title: string }[];
threats: string[];
vulnerabilities: string[];
}) { }) {
const t = await getTranslations("risks"); const t = await getTranslations("risks");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
@@ -310,7 +313,14 @@ export async function RiskEditModal({
action={updateRisk.bind(null, risk.id)} action={updateRisk.bind(null, risk.id)}
className="space-y-4" className="space-y-4"
> >
<RiskFields risk={risk} users={users} processes={processes} selectClass={selectClass} /> <RiskFields
risk={risk}
users={users}
processes={processes}
threats={threats}
vulnerabilities={vulnerabilities}
selectClass={selectClass}
/>
<Button type="submit">{tc("save")}</Button> <Button type="submit">{tc("save")}</Button>
</form> </form>
@@ -357,13 +367,12 @@ export async function RiskEditModal({
<p className="font-medium">{t("measures")}</p> <p className="font-medium">{t("measures")}</p>
<p className="mb-1 text-xs text-muted-foreground">{t("residualAuto")}</p> <p className="mb-1 text-xs text-muted-foreground">{t("residualAuto")}</p>
{risk.riskMeasures.length === 0 && <p>{tc("none")}</p>} {risk.riskMeasures.length === 0 && <p>{tc("none")}</p>}
<ul className="space-y-1.5"> <ul className="space-y-2">
{risk.riskMeasures.map((rm) => ( {risk.riskMeasures.map((rm) => (
<li key={rm.id} className="flex flex-wrap items-center gap-2"> <li key={rm.id} className="rounded-lg border p-2.5">
<b>{measureRef(rm.measure.refNo)}</b> {rm.measure.title} <div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> <b>{measureRef(rm.measure.refNo)}</b>
({rm.reductionLikelihood} / {rm.reductionImpact}) <span className="min-w-0 flex-1 truncate">{rm.measure.title}</span>
</span>
<form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}> <form action={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}>
<button <button
type="submit" type="submit"
@@ -373,6 +382,24 @@ export async function RiskEditModal({
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
</form> </form>
</div>
{/* Minderung nachträglich anpassen (upsert über linkMeasureToRisk) */}
<form
key={rm.reductionLikelihood + "-" + rm.reductionImpact}
action={linkMeasureToRisk.bind(null, risk.id)}
className="mt-2 flex items-end gap-2"
>
<input type="hidden" name="measureId" value={rm.measureId} />
<div className="flex-1">
<ReductionInputs
defaultL={rm.reductionLikelihood}
defaultI={rm.reductionImpact}
/>
</div>
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
<Check className="size-3.5" />
</Button>
</form>
</li> </li>
))} ))}
</ul> </ul>
@@ -390,7 +417,7 @@ export async function RiskEditModal({
</option> </option>
))} ))}
</select> </select>
<ReductionSelects selectClass={selectClass} /> <ReductionInputs />
<Button type="submit" variant="secondary" size="sm"> <Button type="submit" variant="secondary" size="sm">
{t("linkMeasure")} {t("linkMeasure")}
</Button> </Button>
@@ -403,7 +430,7 @@ export async function RiskEditModal({
> >
<p className="text-xs font-semibold">{t("newMeasure")}</p> <p className="text-xs font-semibold">{t("newMeasure")}</p>
<Input name="title" required placeholder={t("measureTitle")} /> <Input name="title" required placeholder={t("measureTitle")} />
<ReductionSelects selectClass={selectClass} /> <ReductionInputs />
<Button type="submit" variant="secondary" size="sm"> <Button type="submit" variant="secondary" size="sm">
{tc("add")} {tc("add")}
</Button> </Button>
@@ -423,30 +450,52 @@ export async function RiskEditModal({
); );
} }
/** Minderungs-Auswahl (Wahrscheinlichkeit/Schaden) für Maßnahmen-Verknüpfungen. */ /** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */
async function ReductionSelects({ selectClass }: { selectClass: string }) { 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 (
<span className="text-xs text-muted-foreground">
{t("reduction")}: {num(l)} {t("likelihoodShort")} / {num(i)} {t("damageShort")}
</span>
);
}
/** Minderungs-Eingaben (dezimal, 0,004,00) für Maßnahmen-Verknüpfungen. */
async function ReductionInputs({
defaultL = 0,
defaultI = 0,
}: {
defaultL?: number;
defaultI?: number;
}) {
const t = await getTranslations("risks"); const t = await getTranslations("risks");
return ( return (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[11px] text-muted-foreground">{t("reductionL")}</label> <label className="text-[11px] text-muted-foreground">{t("reductionL")}</label>
<select name="reductionLikelihood" defaultValue={0} className={`${selectClass} w-full`}> <Input
{REDUCTIONS.map((v) => ( name="reductionLikelihood"
<option key={v} value={v}> type="number"
{v} min={0}
</option> max={4}
))} step={0.05}
</select> defaultValue={defaultL}
className="h-9"
/>
</div> </div>
<div> <div>
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label> <label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
<select name="reductionImpact" defaultValue={0} className={`${selectClass} w-full`}> <Input
{REDUCTIONS.map((v) => ( name="reductionImpact"
<option key={v} value={v}> type="number"
{v} min={0}
</option> max={4}
))} step={0.05}
</select> defaultValue={defaultI}
className="h-9"
/>
</div> </div>
</div> </div>
); );
@@ -457,11 +506,15 @@ async function RiskFields({
risk, risk,
users, users,
processes, processes,
threats,
vulnerabilities,
selectClass, selectClass,
}: { }: {
risk?: RiskWithDetail; risk?: RiskWithDetail;
users: { id: string; name: string }[]; users: { id: string; name: string }[];
processes: { id: string; name: string }[]; processes: { id: string; name: string }[];
threats: string[];
vulnerabilities: string[];
selectClass: string; selectClass: string;
}) { }) {
const t = await getTranslations("risks"); const t = await getTranslations("risks");
@@ -488,16 +541,35 @@ async function RiskFields({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="threat">{t("threat")}</Label> <Label htmlFor="threat">{t("threat")}</Label>
<Input id="threat" name="threat" defaultValue={risk?.threat ?? ""} className="mt-1" /> <Input
id="threat"
name="threat"
list="threat-catalog"
placeholder={t("catalogHint")}
defaultValue={risk?.threat ?? ""}
className="mt-1"
/>
<datalist id="threat-catalog">
{threats.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div> </div>
<div> <div>
<Label htmlFor="vulnerability">{t("vulnerability")}</Label> <Label htmlFor="vulnerability">{t("vulnerability")}</Label>
<Input <Input
id="vulnerability" id="vulnerability"
name="vulnerability" name="vulnerability"
list="vulnerability-catalog"
placeholder={t("catalogHint")}
defaultValue={risk?.vulnerability ?? ""} defaultValue={risk?.vulnerability ?? ""}
className="mt-1" className="mt-1"
/> />
<datalist id="vulnerability-catalog">
{vulnerabilities.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div> </div>
<div> <div>
<Label htmlFor="likelihood">{t("likelihood")}</Label> <Label htmlFor="likelihood">{t("likelihood")}</Label>
@@ -601,10 +673,14 @@ export async function RiskCreateModal({
users, users,
processes, processes,
preselectedAsset, preselectedAsset,
threats,
vulnerabilities,
}: { }: {
users: { id: string; name: string }[]; users: { id: string; name: string }[];
processes: { id: string; name: string }[]; processes: { id: string; name: string }[];
preselectedAsset?: { id: string; name: string } | null; preselectedAsset?: { id: string; name: string } | null;
threats: string[];
vulnerabilities: string[];
}) { }) {
const t = await getTranslations("risks"); const t = await getTranslations("risks");
const tc = await getTranslations("common"); const tc = await getTranslations("common");
@@ -619,7 +695,13 @@ export async function RiskCreateModal({
> >
<form action={createRisk} className="space-y-4 p-5"> <form action={createRisk} className="space-y-4 p-5">
{preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />} {preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />}
<RiskFields users={users} processes={processes} selectClass={selectClass} /> <RiskFields
users={users}
processes={processes}
threats={threats}
vulnerabilities={vulnerabilities}
selectClass={selectClass}
/>
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<Button type="submit">{tc("save")}</Button> <Button type="submit">{tc("save")}</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}> <Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
+7 -1
View File
@@ -18,7 +18,13 @@ const measureSchema = z.object({
dueDate: z.string().optional(), dueDate: z.string().optional(),
}); });
const reduction = z.coerce.number().int().min(0).max(4); // Minderung dezimal (0,004,00) — Komma-Eingaben werden akzeptiert
const reduction = z
.preprocess(
(v) => (typeof v === "string" ? v.replace(",", ".") : v),
z.coerce.number().min(0).max(4)
)
.transform((v) => Math.round(v * 100) / 100);
function parseMeasureForm(formData: FormData) { function parseMeasureForm(formData: FormData) {
const parsed = measureSchema.parse({ const parsed = measureSchema.parse({
+11 -6
View File
@@ -3,10 +3,15 @@ import type { TenantDb } from "./db";
/** /**
* Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell * Rest-Risiko-Berechnung (SPEC §4.2): Das Rest-Risiko wird NICHT manuell
* gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede * gepflegt, sondern ergibt sich aus den verknüpften Maßnahmen. Jede
* Verknüpfung trägt eine erwartete Minderung (Stufen 04) je Dimension; * Verknüpfung trägt eine erwartete Minderung (0,004,00) je Dimension
* die Minderungen werden summiert und von der Brutto-Bewertung abgezogen * bewusst dezimal: oft senkt erst die Summe mehrerer Maßnahmen eine
* (Untergrenze 1). Ohne Maßnahmen gibt es kein Rest-Risiko (null). * Dimension um einen vollen Punkt. Die Minderungen werden summiert und
* von der Brutto-Bewertung abgezogen (Untergrenze 1). Ohne Maßnahmen
* gibt es kein Rest-Risiko (null).
*/ */
const round2 = (v: number) => Math.round(v * 100) / 100;
export async function recomputeResidualRisk(db: TenantDb, riskId: string) { export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
const risk = await db.risk.findUnique({ const risk = await db.risk.findUnique({
where: { id: riskId }, where: { id: riskId },
@@ -24,15 +29,15 @@ export async function recomputeResidualRisk(db: TenantDb, riskId: string) {
const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0); const reductionL = risk.riskMeasures.reduce((s, m) => s + m.reductionLikelihood, 0);
const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0); const reductionI = risk.riskMeasures.reduce((s, m) => s + m.reductionImpact, 0);
const residualLikelihood = Math.max(1, risk.likelihood - reductionL); const residualLikelihood = round2(Math.max(1, risk.likelihood - reductionL));
const residualImpact = Math.max(1, risk.impact - reductionI); const residualImpact = round2(Math.max(1, risk.impact - reductionI));
await db.risk.update({ await db.risk.update({
where: { id: riskId }, where: { id: riskId },
data: { data: {
residualLikelihood, residualLikelihood,
residualImpact, residualImpact,
residualScore: residualLikelihood * residualImpact, residualScore: round2(residualLikelihood * residualImpact),
}, },
}); });
} }