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", "schluessel_slug_gueltig", "schluessel_geloescht_ohne_private_key", ].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[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 Slugs zu, die in Dateinamen passen (#012)", async () => { for (const slug of ["Gross", "mit leer", "../x", "a_b", "", "x".repeat(65)]) { await expect(schluesselAnlegen({ slug })).rejects.toThrow(); } await expect(schluesselAnlegen({ slug: "backup-srv-pve01" })).resolves.toBeDefined(); }); it("kennt „Private Key geloescht“ nur ohne Chiffrat (#014)", async () => { await expect( schluesselAnlegen({ privatGeloeschtAm: new Date(), privatVerschluesselt: Buffer.from("x"), dekVerschluesselt: Buffer.from("y"), masterKeyVersion: 1, }), ).rejects.toThrow(); await expect(schluesselAnlegen({ privatGeloeschtAm: new Date() })).resolves.toBeDefined(); }); 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(); }); });