Alle Modelle fuer die fuenf Phasen in einer Migration, damit spaetere Phasen keine Umbauten an Kerntabellen brauchen. Was auch an der Service-Schicht vorbei gelten muss, steht als CHECK, partieller Index oder Trigger in der Datenbank: Personen-Keys ohne Private Key, genau ein Instanz-Deployment-Key, ein vertrauter Host-Key je Algorithmus, shell-sichere Zielbenutzer und Pfade. Das Protokoll ist append-only und per Hash-Kette verbunden; den Hash berechnet ein Trigger, damit keine Zeile ohne Verkettung entstehen kann (#005 baut darauf auf). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
8.8 KiB
TypeScript
226 lines
8.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { prisma } from "@/lib/db";
|
|
import { datenbankLeeren } from "./hilfen/db";
|
|
|
|
/**
|
|
* Regeln, die in der Datenbank stehen (#003, #005).
|
|
*
|
|
* Die Liste ist **namentlich**: eine neue Regel in der Migration ohne Eintrag hier faellt
|
|
* im ersten Test auf.
|
|
*/
|
|
const REGELN = [
|
|
"schluessel_umschlag_vollstaendig",
|
|
"schluessel_person_ohne_private_key",
|
|
"schluessel_instanzstandard_nur_deployment",
|
|
"publicverwendung_zielbenutzer_gueltig",
|
|
"privateverwendung_zielbenutzer_gueltig",
|
|
"zielkonto_benutzername_gueltig",
|
|
"host_adresse_gueltig",
|
|
"host_port_gueltig",
|
|
"privateverwendung_pfad_gueltig",
|
|
"publicverwendung_host_oder_gruppe",
|
|
"benutzer_notfall_vollstaendig",
|
|
].sort();
|
|
|
|
const INDIZES = [
|
|
"schluessel_ein_instanzstandard",
|
|
"publicverwendung_eindeutig_host",
|
|
"publicverwendung_eindeutig_gruppe",
|
|
"privateverwendung_eindeutig",
|
|
"hostschluessel_ein_vertrauter",
|
|
].sort();
|
|
|
|
let zaehler = 0;
|
|
async function schluesselAnlegen(
|
|
daten: Partial<Parameters<typeof prisma.schluessel.create>[0]["data"]> = {},
|
|
) {
|
|
zaehler += 1;
|
|
return prisma.schluessel.create({
|
|
data: {
|
|
name: `Test ${zaehler}`,
|
|
slug: `test-${zaehler}`,
|
|
typ: "ED25519",
|
|
kategorie: "DIENST",
|
|
publicKey: "ssh-ed25519 AAAA test",
|
|
fingerprint: `SHA256:test${zaehler}`,
|
|
kommentar: "test",
|
|
...daten,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function hostAnlegen(name = "srv-test01") {
|
|
return prisma.host.create({ data: { name, adresse: "10.0.0.1" } });
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await datenbankLeeren();
|
|
});
|
|
|
|
describe("Datenbankregeln", () => {
|
|
it("sind vollstaendig aufgezaehlt", async () => {
|
|
const checks = await prisma.$queryRaw<{ conname: string }[]>`
|
|
SELECT conname FROM pg_constraint WHERE contype = 'c' AND connamespace = 'public'::regnamespace`;
|
|
expect(checks.map((c) => c.conname).sort()).toEqual(REGELN);
|
|
|
|
const indizes = await prisma.$queryRaw<{ indexname: string }[]>`
|
|
SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexdef LIKE '% WHERE %'`;
|
|
expect(indizes.map((i) => i.indexname).sort()).toEqual(INDIZES);
|
|
});
|
|
|
|
it("verlangt Chiffrat, Data-Key und Version nur zusammen", async () => {
|
|
await expect(schluesselAnlegen({ privatVerschluesselt: Buffer.from("x") })).rejects.toThrow();
|
|
await expect(
|
|
schluesselAnlegen({
|
|
privatVerschluesselt: Buffer.from("x"),
|
|
dekVerschluesselt: Buffer.from("y"),
|
|
masterKeyVersion: 1,
|
|
}),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("speichert keine Private Keys von Personen", async () => {
|
|
await expect(
|
|
schluesselAnlegen({
|
|
kategorie: "PERSON",
|
|
privatVerschluesselt: Buffer.from("x"),
|
|
dekVerschluesselt: Buffer.from("y"),
|
|
masterKeyVersion: 1,
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("laesst nur einen aktiven Instanz-Deployment-Key zu", async () => {
|
|
await expect(schluesselAnlegen({ kategorie: "DIENST", instanzStandard: true })).rejects.toThrow();
|
|
await schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true });
|
|
await expect(schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true })).rejects.toThrow();
|
|
// Ein rotierter alter Deployment-Key darf daneben stehen bleiben.
|
|
await expect(
|
|
schluesselAnlegen({ kategorie: "DEPLOYMENT", instanzStandard: true, status: "ROTIERT" }),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("weist Zielbenutzer und Adressen ab, die in einer Shell Schaden anrichten koennten", async () => {
|
|
const host = await hostAnlegen();
|
|
const schluessel = await schluesselAnlegen();
|
|
for (const zielbenutzer of ["root; rm -rf /", "Root", "a b", "$(id)", ""]) {
|
|
await expect(
|
|
prisma.publicVerwendung.create({
|
|
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer },
|
|
}),
|
|
).rejects.toThrow();
|
|
}
|
|
await expect(prisma.host.create({ data: { name: "x", adresse: "host;id" } })).rejects.toThrow();
|
|
await expect(
|
|
prisma.host.create({ data: { name: "y", adresse: "10.0.0.2", port: 70000 } }),
|
|
).rejects.toThrow();
|
|
await expect(prisma.host.create({ data: { name: "z", adresse: "fe80::1" } })).resolves.toBeDefined();
|
|
});
|
|
|
|
it("prueft Private-Key-Pfade", async () => {
|
|
const host = await hostAnlegen();
|
|
const schluessel = await schluesselAnlegen();
|
|
const anlegen = (pfad: string) =>
|
|
prisma.privateVerwendung.create({
|
|
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad },
|
|
});
|
|
await expect(anlegen("~/.ssh/id_ed25519_backup")).resolves.toBeDefined();
|
|
await expect(anlegen("/root/.ssh/id_x")).resolves.toBeDefined();
|
|
for (const pfad of ["relativ/pfad", "~/../etc/shadow", "/etc/../x", "~/a b", "~/x\ny"]) {
|
|
await expect(anlegen(pfad)).rejects.toThrow();
|
|
}
|
|
});
|
|
|
|
it("verlangt bei einer Public-Verwendung genau Host oder Gruppe", async () => {
|
|
const host = await hostAnlegen();
|
|
const gruppe = await prisma.hostgruppe.create({ data: { name: "Proxmox" } });
|
|
const schluessel = await schluesselAnlegen();
|
|
await expect(
|
|
prisma.publicVerwendung.create({ data: { schluesselId: schluessel.id, zielbenutzer: "root" } }),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
prisma.publicVerwendung.create({
|
|
data: { schluesselId: schluessel.id, hostId: host.id, hostgruppeId: gruppe.id, zielbenutzer: "root" },
|
|
}),
|
|
).rejects.toThrow();
|
|
await prisma.publicVerwendung.create({
|
|
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
|
|
});
|
|
// Doppelt nur, solange die erste nicht entfernt ist.
|
|
await expect(
|
|
prisma.publicVerwendung.create({
|
|
data: { schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("laesst je Host und Algorithmus nur einen vertrauten Host-Key zu", async () => {
|
|
const host = await hostAnlegen();
|
|
const basis = { hostId: host.id, algorithmus: "ssh-ed25519", publicKey: "AAAA" };
|
|
await prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:a", status: "VERTRAUT" } });
|
|
await expect(
|
|
prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:b", status: "VERTRAUT" } }),
|
|
).rejects.toThrow();
|
|
await expect(
|
|
prisma.hostSchluessel.create({ data: { ...basis, fingerprint: "SHA256:b", status: "AUSSTEHEND" } }),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("verlangt beim Notfall-Admin Anmeldename und Passwort", async () => {
|
|
await expect(prisma.benutzer.create({ data: { name: "Notfall", istNotfall: true } })).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Protokoll", () => {
|
|
async function eintragen(aktion: string) {
|
|
return prisma.protokoll.create({
|
|
data: { akteurName: "Test", aktion, ergebnis: "ERFOLG", details: { a: 1 } },
|
|
});
|
|
}
|
|
|
|
it("verkettet jede Zeile mit ihrer Vorgaengerin", async () => {
|
|
const erster = await eintragen("eins");
|
|
const zweiter = await eintragen("zwei");
|
|
expect(erster.vorgaengerHash).toBe("");
|
|
expect(erster.hash).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(zweiter.vorgaengerHash).toBe(erster.hash);
|
|
const [pruefung] = await prisma.$queryRaw<
|
|
{ ergebnis: bigint | null }[]
|
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
|
expect(pruefung?.ergebnis).toBeNull();
|
|
});
|
|
|
|
it("verweigert Aendern, Loeschen und Leeren", async () => {
|
|
const eintrag = await eintragen("eins");
|
|
await expect(
|
|
prisma.protokoll.update({ where: { id: eintrag.id }, data: { aktion: "x" } }),
|
|
).rejects.toThrow(/unveränderlich/);
|
|
await expect(prisma.protokoll.delete({ where: { id: eintrag.id } })).rejects.toThrow(/unveränderlich/);
|
|
await expect(prisma.$executeRawUnsafe(`TRUNCATE "Protokoll"`)).rejects.toThrow(/unveränderlich/);
|
|
});
|
|
|
|
it("findet eine manipulierte Zeile", async () => {
|
|
await eintragen("eins");
|
|
const zweiter = await eintragen("zwei");
|
|
await eintragen("drei");
|
|
// Ein Angreifer mit Datenbankrechten schaltet den Trigger ab und aendert eine Zeile.
|
|
await prisma.$transaction([
|
|
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`),
|
|
prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`),
|
|
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`),
|
|
]);
|
|
const [pruefung] = await prisma.$queryRaw<
|
|
{ ergebnis: bigint | null }[]
|
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
|
expect(pruefung?.ergebnis).toBe(zweiter.id);
|
|
});
|
|
|
|
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
|
|
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
|
|
const [pruefung] = await prisma.$queryRaw<
|
|
{ ergebnis: bigint | null }[]
|
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
|
expect(pruefung?.ergebnis).toBeNull();
|
|
});
|
|
});
|