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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ export async function vorgangLaufen(
|
||||
| "AUSROLLEN"
|
||||
| "VERBINDUNGSTEST"
|
||||
| "ZIELKONTO_ANLEGEN"
|
||||
| "ZIELKONTO_ENTFERNEN",
|
||||
| "ZIELKONTO_ENTFERNEN"
|
||||
| "ABGLEICH",
|
||||
daten: {
|
||||
hostId?: string;
|
||||
eingabe?: object;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { authorizedKeysAbgleichen } from "@/lib/ssh/abgleich";
|
||||
import { blockSetzen, type SollEintrag } from "@/lib/ssh/authorized-keys";
|
||||
import { fingerprintSha256 } from "@/lib/ssh/openssh";
|
||||
|
||||
/** Abgleich einer authorized_keys mit dem Soll (#021). */
|
||||
|
||||
function key(kommentar: string) {
|
||||
const { publicKey } = generateKeyPairSync("ed25519");
|
||||
const roh = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
|
||||
const typ = Buffer.from("ssh-ed25519");
|
||||
const blob = Buffer.concat([Buffer.from([0, 0, 0, typ.length]), typ, Buffer.from([0, 0, 0, 32]), roh]);
|
||||
return {
|
||||
publicKey: `ssh-ed25519 ${blob.toString("base64")} ${kommentar}`,
|
||||
fingerprint: fingerprintSha256(blob),
|
||||
};
|
||||
}
|
||||
|
||||
const DEPLOY = key("deploy");
|
||||
const BACKUP = key("backup");
|
||||
const FREMD = key("hacker@irgendwo");
|
||||
const BEKANNT = key("bekannt");
|
||||
|
||||
const soll: SollEintrag[] = [DEPLOY, { ...BACKUP, optionen: 'from="10.0.0.1"' }];
|
||||
const bekannt = new Set([DEPLOY.fingerprint, BACKUP.fingerprint, BEKANNT.fingerprint]);
|
||||
|
||||
describe("authorized_keys abgleichen", () => {
|
||||
it("ist ohne Befund, wenn der Block dem Soll entspricht", () => {
|
||||
const inhalt = blockSetzen(`${BEKANNT.publicKey}\n`, soll).inhalt;
|
||||
const ergebnis = authorizedKeysAbgleichen(inhalt, soll, bekannt, {
|
||||
sshVerzeichnis: "700",
|
||||
datei: "600",
|
||||
eigentuemerPasst: true,
|
||||
});
|
||||
expect(ergebnis.befunde).toEqual([]);
|
||||
expect(ergebnis.vorhanden).toEqual([DEPLOY.fingerprint, BACKUP.fingerprint]);
|
||||
expect(ergebnis.eintraege.map((e) => e.herkunft)).toEqual(["bekannt", "verwaltet", "verwaltet"]);
|
||||
});
|
||||
|
||||
it("findet fehlende, geaenderte und zusaetzliche Keys im Block", () => {
|
||||
const inhalt = blockSetzen("", [DEPLOY, BACKUP, FREMD]).inhalt; // BACKUP ohne Optionen, FREMD zu viel
|
||||
const arten = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde.map((b) => [b.art, b.fingerprint]);
|
||||
expect(arten).toEqual([
|
||||
["ZUSAETZLICH_IM_BLOCK", BACKUP.fingerprint],
|
||||
["ZUSAETZLICH_IM_BLOCK", FREMD.fingerprint],
|
||||
]);
|
||||
const leer = authorizedKeysAbgleichen(null, soll, bekannt).befunde.map((b) => b.art);
|
||||
expect(leer).toEqual(["FEHLENDER_SCHLUESSEL", "FEHLENDER_SCHLUESSEL"]);
|
||||
});
|
||||
|
||||
it("meldet nur unbekannte Keys ausserhalb des Blocks", () => {
|
||||
const inhalt = blockSetzen(`${FREMD.publicKey}\n${FREMD.publicKey}\n${BEKANNT.publicKey}\n`, soll).inhalt;
|
||||
const befunde = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde;
|
||||
expect(befunde).toHaveLength(1);
|
||||
expect(befunde[0]).toMatchObject({ art: "UNBEKANNTER_SCHLUESSEL", fingerprint: FREMD.fingerprint });
|
||||
expect(befunde[0]?.text).toContain("hacker@irgendwo");
|
||||
});
|
||||
|
||||
it("prueft Rechte und Eigentuemer", () => {
|
||||
const befunde = authorizedKeysAbgleichen("", [], bekannt, {
|
||||
sshVerzeichnis: "777",
|
||||
datei: "664",
|
||||
eigentuemerPasst: false,
|
||||
}).befunde;
|
||||
expect(befunde.map((b) => b.art)).toEqual(["RECHTE", "RECHTE", "RECHTE"]);
|
||||
expect(
|
||||
authorizedKeysAbgleichen("", [], bekannt, {
|
||||
sshVerzeichnis: "755",
|
||||
datei: "644",
|
||||
eigentuemerPasst: true,
|
||||
}).befunde,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("meldet einen beschaedigten Block", () => {
|
||||
expect(authorizedKeysAbgleichen("# BEGIN SSHKV MANAGED\n", [], bekannt).befunde[0]?.art).toBe(
|
||||
"DATEI_GEAENDERT",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -209,3 +209,14 @@ describe("Diff", () => {
|
||||
expect(kurz[0]?.text).toMatch(/8 unveränderte Zeile/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uebernommene Keys (#022)", () => {
|
||||
it("entfernt nur ausdruecklich genannte Zeilen ausserhalb des Blocks", () => {
|
||||
const vorher = `# Kommentar\n${FREMD.zeile}\n${BACKUP.zeile}\n`;
|
||||
const ergebnis = blockSetzen(vorher, soll(FREMD), [], [FREMD.fingerprint]);
|
||||
expect(ergebnis.inhalt).toBe(
|
||||
`# Kommentar\n${BACKUP.zeile}\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${FREMD.zeile}\n# END SSHKV MANAGED\n`,
|
||||
);
|
||||
expect(ergebnis.hinweise).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user