Files
sshkeyvault/tests/integration/abgleich.test.ts
T
florian.wildeandClaude Opus 5.5 e518e75022 feat(abgleich): Drift-Erkennung, Zeitplan und Uebernahme (#021, #022)
Ein Abgleich liest je Host alle authorized_keys in einem SSH-Aufruf,
als jeweiliger Benutzer, und vergleicht sie mit dem Soll: fehlende,
von Hand geaenderte und unbekannte Keys, falsche Rechte, beschaedigte
Bloecke und veraenderte Private Keys. Befunde werden geoeffnet und
wieder geschlossen, Zustaende von Verwendungen und Host nachgezogen.
Auf dem Host aendert der Abgleich nichts.

Der Zeitplan liegt in pg-boss und feuert auch bei mehreren Workern
nur einmal. Auf der Hostseite stehen Abweichungen und der Ist-Zustand
je Benutzer; unbekannte Keys lassen sich uebernehmen - nur bekannt
machen oder mit Dry-Run in den verwalteten Block holen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:02:11 +02:00

170 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { schluesselpaarErzeugen } from "@/lib/ssh/openssh";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import { keyUebernehmen } from "@/server/services/uebernahme";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "../hilfen/db";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele";
/** Abgleich und Drift-Erkennung gegen ein echtes sshd (#021). */
const AKTEUR = { id: "test", name: "Test" };
let aufraeumen: () => void;
beforeAll(async () => {
await zielBereit(zielA);
aufraeumen = masterKeyAnlegen();
});
afterAll(() => aufraeumen());
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
await befehlAlsRoot(
zielA,
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true",
);
});
async function ausgerollterHost() {
const host = await eingerichteterHost(zielA, "ziel-a");
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:abgleich" });
const schluessel = await prisma.schluessel.create({
data: {
name: "Abgleich",
slug: "abgleich",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:abgleich",
},
});
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
AKTEUR,
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId: schluessel.id }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
return { host, schluessel };
}
const abgleichen = (hostId: string) => vorgangLaufen("ABGLEICH", { hostId });
// Postgres sortiert Enums nach Deklaration; fuer lesbare Erwartungen alphabetisch in JS.
const offen = async (hostId: string) =>
(await prisma.abweichung.findMany({ where: { hostId, behobenAm: null } })).sort((a, b) =>
a.art.localeCompare(b.art),
);
describe("Abgleich", () => {
it("ist nach dem Ausrollen synchron und haelt den Ist-Zustand je Benutzer fest", async () => {
const { host } = await ausgerollterHost();
const vorgang = await abgleichen(host.id);
expect(vorgang.fehler).toBeNull();
expect(await offen(host.id)).toEqual([]);
const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(aktuell.driftZustand).toBe("SYNCHRON");
expect(aktuell.letzterAbgleichAm).not.toBeNull();
const beobachtet = await prisma.beobachtung.findMany({
where: { hostId: host.id },
orderBy: { zielbenutzer: "asc" },
});
expect(beobachtet.map((b) => b.zielbenutzer)).toEqual(["root", "sshkv"]);
expect(JSON.stringify(beobachtet[0]?.eintraege)).toContain('"herkunft":"verwaltet"');
});
it("findet fremde Keys, fehlende Keys und falsche Rechte – und schliesst behobene Befunde", async () => {
const { host } = await ausgerollterHost();
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "fremd@laptop" });
await befehlAlsRoot(
zielA,
`echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys && sed -i '/sshkv:abgleich/d' /root/.ssh/authorized_keys && chmod 777 /root/.ssh`,
);
await abgleichen(host.id);
const arten = (await offen(host.id)).map((a) => a.art);
expect(arten).toEqual(["FEHLENDER_SCHLUESSEL", "RECHTE", "UNBEKANNTER_SCHLUESSEL"]);
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT");
expect((await prisma.publicVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
// Zweiter Lauf ohne Aenderung: keine doppelten Befunde.
await abgleichen(host.id);
expect(await offen(host.id)).toHaveLength(3);
// Von Hand bereinigen: fremden Key raus, Rechte zurueck – der fehlende Key bleibt Befund.
await befehlAlsRoot(zielA, "sed -i '/fremd@laptop/d' /root/.ssh/authorized_keys && chmod 700 /root/.ssh");
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["FEHLENDER_SCHLUESSEL"]);
});
it("bemerkt einen veraenderten Private Key", async () => {
const host = await eingerichteterHost(zielA, "ziel-a");
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:privat");
const schluessel = await prisma.schluessel.create({
data: {
id: neu.id,
name: "Privat",
slug: "privat",
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: "sshkv:privat",
...neu.versiegelt,
},
});
await privateVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad: "~/.ssh/id_privat" },
AKTEUR,
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonPrivatVerwendungen({ schluesselId: schluessel.id }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
await abgleichen(host.id);
expect(await offen(host.id)).toEqual([]);
await befehlAlsRoot(zielA, "echo kaputt > /root/.ssh/id_privat");
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["PRIVATE_KEY_FEHLT"]);
expect((await prisma.privateVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
});
it("uebernimmt einen fremden Key in den verwalteten Block (#022)", async () => {
const { host } = await ausgerollterHost();
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "anna@laptop" });
await befehlAlsRoot(zielA, `sed -i '1i no-pty ${fremd.publicKeyZeile}' /root/.ssh/authorized_keys`);
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["UNBEKANNTER_SCHLUESSEL"]);
const admin = await prisma.benutzer.create({ data: { id: "test", name: "Test", rolle: "ADMIN" } });
const { schluesselId } = await keyUebernehmen(
{
hostId: host.id,
zielbenutzer: "root",
fingerprint: fremd.fingerprint,
name: "Laptop Anna",
kategorie: "PERSON",
personId: null,
verwalten: true,
},
{ id: admin.id, name: admin.name },
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
const datei = (await befehlAlsRoot(zielA, "cat /root/.ssh/authorized_keys")).stdout;
expect(datei.startsWith("# BEGIN SSHKV MANAGED")).toBe(true);
expect(datei.match(/anna@laptop/g)).toHaveLength(1);
expect(datei).toContain(`no-pty ${fremd.publicKeyZeile}`);
await abgleichen(host.id);
expect(await offen(host.id)).toEqual([]);
});
});