import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { KOMMENTAR_ZEICHEN, ShellFehler, type SkriptSchritt, UMBRUCH, skriptBauen, wertMaskieren, } from "@/lib/shell"; /** * Shell-Maskierung und Skript-Kopie (#012). * * Die Zeichenketten-Vergleiche halten fest, **was** maskiert wird. Sie beweisen * aber nicht, dass die Maskierung traegt — das tut nur die Shell selbst. Fuer * Bash geschieht das hier wirklich: der maskierte Wert wird an `bash -c` * uebergeben und es wird geprueft, dass genau **ein** Argument ankommt, dass es * Zeichen fuer Zeichen dem Ausgangswert gleicht und dass nichts ausgefuehrt * wurde. Fuer PowerShell steht dieselbe Probe bereit, laeuft aber nur, wo `pwsh` * vorhanden ist. */ /** Ein Wert, der alles versammelt, was eine Shell gefaehrlich findet. */ const BOESARTIG = `O'Brien"; rm -rf / && echo $(whoami) \`id\` $HOME | tee %PATH% !x!\nzweite Zeile`; /** * Derselbe Angriff mit **typografischen** Anfuehrungszeichen. * * PowerShell liest `“`, `”` und `„` als doppeltes und `‘`, `’`, `‚`, `‛` als * einfaches Anfuehrungszeichen (about_Quoting_Rules, gegen PowerShell 7 * nachgemessen). Bis zum Review von #012 gingen sie unmaskiert durch — ein * Abteilungsname, den jemand aus Word kopiert, reichte fuer Codeausfuehrung auf * dem Domaenencontroller. */ const TYPOGRAFISCH = "Mueller\u201D; New-Item -ItemType File -Path /tmp/marke > $null; \u201CX"; function vorhanden(programm: string): boolean { try { execFileSync(programm, ["--version"], { stdio: "ignore" }); return true; } catch { return false; } } const bashDa = vorhanden("bash"); /** * Wie PowerShell hier erreichbar ist – und warum das nicht egal ist. * * Diese Maschine hat kein `pwsh`. Der Test dagegen hat sich deshalb bisher * **still uebersprungen**, und ein stilles Ueberspringen sieht genauso gruen aus * wie ein bestandener Lauf — genau die Falle, die `tests/helpers/db.ts` fuer die * Datenbank bewusst zugemacht hat. Der Review von #012 hat den Blocker mit den * typografischen Anfuehrungszeichen erst gefunden, nachdem er sich PowerShell * aus einem Container geholt hat. * * Also: erst `pwsh`, sonst das Container-Abbild, und in CI gar kein * Ueberspringen mehr. */ function pwshAufruf(): { readonly programm: string; readonly vorlauf: readonly string[] } | undefined { if (vorhanden("pwsh")) return { programm: "pwsh", vorlauf: [] }; try { execFileSync("docker", ["image", "inspect", PWSH_ABBILD], { stdio: "ignore" }); return { programm: "docker", vorlauf: ["run", "--rm", "-i", PWSH_ABBILD, "pwsh"] }; } catch { return undefined; } } const PWSH_ABBILD = "mcr.microsoft.com/powershell:latest"; const pwsh = pwshAufruf(); if (!pwsh && process.env["CI"]) { throw new Error( `Weder pwsh noch das Abbild ${PWSH_ABBILD} ist da. In CI darf die PowerShell-Maskierung ` + "nicht ungeprueft bleiben: `docker pull " + PWSH_ABBILD + "`.", ); } describe("wertMaskieren fuer PowerShell", () => { it("verdoppelt das doppelte Anfuehrungszeichen", () => { expect(wertMaskieren('Jörg "der Chef" Müller', "powershell")).toBe('Jörg ""der Chef"" Müller'); }); it("verdoppelt den Backtick", () => { expect(wertMaskieren("a`b", "powershell")).toBe("a``b"); }); it("maskiert das Dollarzeichen, damit $(…) nicht ausgefuehrt wird", () => { // Genau hier entscheidet sich, ob ein Wert Text bleibt oder zu einem // Unterausdruck wird, den PowerShell beim Aufbau der Zeichenkette ausfuehrt. expect(wertMaskieren("$(rm -rf /)", "powershell")).toBe("`$(rm -rf /)"); expect(wertMaskieren("$env:PATH", "powershell")).toBe("`$env:PATH"); }); it("maskiert Backtick und Dollar in der richtigen Reihenfolge", () => { // Zuerst der Backtick, dann das Dollarzeichen: umgekehrt wuerde die // Maskierung `$ am Ende selbst wieder verdoppelt und das $ waere frei. // "```$x" bedeutet in PowerShell: literaler Backtick, literales Dollar, x. expect(wertMaskieren("`$x", "powershell")).toBe("```$x"); }); it("macht aus Zeilenumbruechen die Fluchtfolgen `r und `n", () => { expect(wertMaskieren("erste\r\nzweite", "powershell")).toBe("erste`r`nzweite"); }); it("laesst einen harmlosen Wert unveraendert", () => { expect(wertMaskieren("Jörg Müller-Lüdenscheid", "powershell")).toBe("Jörg Müller-Lüdenscheid"); expect(wertMaskieren("", "powershell")).toBe(""); }); it("maskiert jedes gefaehrliche Zeichen des Sammelwerts", () => { const maskiert = wertMaskieren(BOESARTIG, "powershell"); // Kein unmaskiertes $ und kein einzelnes " bleibt uebrig. expect(maskiert).not.toMatch(/(^|[^`])\$/); expect(maskiert).not.toMatch(/(^|[^"])"([^"]|$)/); expect(maskiert).not.toMatch(/[\r\n]/); }); it("entschaerft typografische Anfuehrungszeichen", () => { /* * Der Blocker aus dem Review von #012: sie beenden eine * PowerShell-Zeichenkette genauso wie `"`, gingen aber unmaskiert durch. * Angeglichen wird auf die ASCII-Form und **dann** verdoppelt — als Text * durchreichen laesst sich das Zeichen nicht, PowerShell liest es ohnehin * als Begrenzer. */ expect(wertMaskieren("a\u201Db", "powershell")).toBe('a""b'); expect(wertMaskieren("a\u201Cb", "powershell")).toBe('a""b'); expect(wertMaskieren("a\u201Eb", "powershell")).toBe('a""b'); expect(wertMaskieren(TYPOGRAFISCH, "powershell")).not.toMatch(/[\u201C\u201D\u201E]/u); // Das typografische Apostroph wird zum geraden – in `"…"` ist es harmlos // und wird deshalb **nicht** verdoppelt. expect(wertMaskieren("O\u2019Neill", "powershell")).toBe("O'Neill"); }); describe.skipIf(!pwsh)("gegen ein echtes PowerShell geprueft", () => { /* * Eine Minute statt der fuenf Sekunden, die Vitest sonst gibt: liegt kein * `pwsh` auf der Maschine, laeuft dieser Test in einem Container, und der * startet erst. Unter Last — ein Ende-zu-Ende-Lauf nebenher genuegt — reichte * die Vorgabe nicht, und der Test kippte um, ohne dass an der Maskierung * etwas falsch war. Ein Test, der gelegentlich grundlos rot ist, wird bald * uebersehen. */ it("kommt als genau ein Argument an und fuehrt nichts aus", { timeout: 90_000 }, () => { for (const wert of [BOESARTIG, TYPOGRAFISCH]) { const maskiert = wertMaskieren(wert, "powershell"); const skript = `$werte = @("${maskiert}"); "$($werte.Count)|$($werte[0])"`; const ausgabe = execFileSync(pwsh!.programm, [...pwsh!.vorlauf, "-NoProfile", "-Command", skript], { encoding: "utf8", timeout: 60_000, }); const erwartet = wert .replace(/[\u201C\u201D\u201E]/gu, '"') .replace(/[\u2018\u2019\u201A\u201B]/gu, "'") .replace(/\r?\n/g, "\n"); expect(ausgabe.trimEnd()).toBe(`1|${erwartet}`); } }); }); }); describe("wertMaskieren fuer Bash", () => { it("bricht das einfache Anfuehrungszeichen heraus", () => { expect(wertMaskieren("O'Brien", "bash")).toBe(`O'\\''Brien`); }); it("laesst alles uebrige unveraendert – in '…' ist nichts davon besonders", () => { expect(wertMaskieren("$HOME `id` \\ ; && | > <", "bash")).toBe("$HOME `id` \\ ; && | > <"); // Ein Umbruch bekommt Bashs eigene Schreibweise: die Zeichenkette wird // geschlossen, das Zeichen als Fluchtfolge angehaengt, wieder geoeffnet. // Sonst veraendert die Skript-Kopie ihn spaeter (sie normalisiert Umbrueche). expect(wertMaskieren("erste\nzweite", "bash")).toBe(`erste'$'\\n''zweite`); expect(wertMaskieren("", "bash")).toBe(""); }); describe.skipIf(!bashDa)("gegen ein echtes bash geprueft", () => { let verzeichnis = ""; let marke = ""; beforeAll(() => { verzeichnis = mkdtempSync(join(tmpdir(), "laufzettel-shell-")); marke = join(verzeichnis, "ausgefuehrt"); }); afterAll(() => { rmSync(verzeichnis, { recursive: true, force: true }); }); /** Gibt zurueck, wie viele Argumente ankamen und was im ersten stand. */ function alsArgument(wert: string): { anzahl: string; erstes: string } { const skript = `set -- '${wertMaskieren(wert, "bash")}'\nprintf '%s\\x1f%s' "$#" "$1"`; const ausgabe = execFileSync("bash", ["-c", skript], { encoding: "utf8" }); const [anzahl = "", erstes = ""] = ausgabe.split("\u001f"); return { anzahl, erstes }; } it.each([ ["einfaches Anfuehrungszeichen", "O'Brien"], ["Befehlsersetzung mit Dollar", "$(whoami)"], ["Befehlsersetzung mit Backtick", "`id`"], ["Trennzeichen", "a; b && c | d"], ["Zeilenumbruch", "erste\nzweite"], ["Rueckwaertsschraegstrich", "C:\\Users\\Jörg"], ["Sternchen und Tilde", "*.* ~ ?"], ["alles zusammen", BOESARTIG], ])("traegt %s als genau ein Argument", (_fall, wert) => { const { anzahl, erstes } = alsArgument(wert); expect(anzahl).toBe("1"); expect(erstes).toBe(wert); }); it("fuehrt nichts aus, was im Wert steht", () => { // Der Beweis ist die Datei, die nicht entsteht: schlaegt die Maskierung // fehl, laeuft `touch` und die Marke liegt da. const wert = `x'; touch ${marke}; echo 'y $(touch ${marke}) \`touch ${marke}\``; const { anzahl, erstes } = alsArgument(wert); expect(anzahl).toBe("1"); expect(erstes).toBe(wert); expect(existsSync(marke)).toBe(false); }); }); }); describe("wertMaskieren fuer Cmd", () => { it('laesst die Trennzeichen stehen – in "…" sind sie gewoehnliche Zeichen', () => { expect(wertMaskieren("a & b | c > d < e ^ f", "cmd")).toBe("a & b | c > d < e ^ f"); expect(wertMaskieren("Jörg Müller-Lüdenscheid", "cmd")).toBe("Jörg Müller-Lüdenscheid"); }); it("laesst Prozent und Ausrufezeichen stehen", () => { // Bewusst und dokumentiert: `%%` waere nur in einer Batch-Datei richtig, in // einer interaktiven Eingabe falsch. Die Folge ist im schlimmsten Fall ein // falscher Wert, nicht fremder Code – Cmd kennt keine Befehlsersetzung. expect(wertMaskieren("%PATH% !x!", "cmd")).toBe("%PATH% !x!"); }); it.each([ ["ein doppeltes Anfuehrungszeichen", 'a" & del /q C:\\*'], ["einen Wagenruecklauf", "a\rb"], ["einen Zeilenumbruch", "a\nb"], ["ein Null-Byte", "a\u0000b"], ])("lehnt einen Wert mit %s ab", (_fall, wert) => { // Ablehnen statt unsicher durchlassen: ein `"` beendet das Argument, und // alles danach liest cmd.exe als Befehl. expect(() => wertMaskieren(wert, "cmd")).toThrow(ShellFehler); }); it("sagt in der Meldung, was im Weg ist und was zu tun ist", () => { expect(() => wertMaskieren('a"b', "cmd")).toThrow(/Anführungszeichen/); expect(() => wertMaskieren('a"b', "cmd")).toThrow(/PowerShell/); }); }); describe("skriptBauen", () => { const powershellSchritte: readonly SkriptSchritt[] = [ { titel: "Konto anlegen", shell: "powershell", befehl: 'New-ADUser -Name "Jörg"' }, { titel: "Gruppen setzen", shell: "powershell", befehl: "Add-ADGroupMember -Identity IT" }, ]; it("reiht die Befehle in der uebergebenen Reihenfolge mit Kommentarzeilen", () => { expect(skriptBauen(powershellSchritte)).toBe( [ "# Schritt: Konto anlegen", 'New-ADUser -Name "Jörg"', "", "# Schritt: Gruppen setzen", "Add-ADGroupMember -Identity IT", "", ].join("\r\n"), ); }); it("trennt die Bloecke durch genau eine Leerzeile", () => { const skript = skriptBauen(powershellSchritte); expect(skript).toContain(`New-ADUser -Name "Jörg"\r\n\r\n# Schritt: Gruppen setzen`); expect(skript).not.toContain("\r\n\r\n\r\n"); }); it("endet mit einem Umbruch, damit die letzte Zeile ausgefuehrt wird", () => { expect(skriptBauen(powershellSchritte).endsWith("\r\n")).toBe(true); }); it("nimmt fuer bash den Zeilenvorschub", () => { const skript = skriptBauen([{ titel: "Anlegen", shell: "bash", befehl: "useradd joerg" }]); expect(skript).toBe("# Schritt: Anlegen\nuseradd joerg\n"); expect(skript).not.toContain("\r"); }); it("nimmt fuer cmd REM und den Wagenruecklauf", () => { const skript = skriptBauen([{ titel: "Anlegen", shell: "cmd", befehl: "net user joerg /add" }]); expect(skript).toBe("REM Schritt: Anlegen\r\nnet user joerg /add\r\n"); expect(KOMMENTAR_ZEICHEN.cmd).toBe("REM"); }); it("nimmt bei gemischten Shells den Zeilenvorschub", () => { // Ein gemischtes Skript ist ohnehin von keinem Interpreter als Ganzes // ausfuehrbar; ein Wagenruecklauf wuerde die bash-Zeilen zusaetzlich // beschaedigen. const skript = skriptBauen([ { titel: "Erst PowerShell", shell: "powershell", befehl: "Get-ADUser joerg" }, { titel: "Dann bash", shell: "bash", befehl: "id joerg" }, ]); expect(skript).not.toContain("\r"); expect(skript).toContain("# Schritt: Erst PowerShell"); expect(skript).toContain("# Schritt: Dann bash"); }); it("bringt die Umbrueche innerhalb eines mehrzeiligen Befehls auf denselben Stand", () => { // Eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine. const skript = skriptBauen([ { titel: "Mehrzeilig", shell: "powershell", befehl: "Zeile eins\nZeile zwei\r\nZeile drei" }, ]); expect(skript).toBe("# Schritt: Mehrzeilig\r\nZeile eins\r\nZeile zwei\r\nZeile drei\r\n"); expect(skript).not.toMatch(/[^\r]\n/); }); it("laesst einen Titel mit Zeilenumbruch den Kommentar nicht sprengen", () => { // Die zweite Haelfte des Titels wuerde sonst als Befehl in der Zeile // darunter stehen – und `format c:` fuehrt niemand aus Versehen aus. const skript = skriptBauen([ { titel: "Konto anlegen\r\nformat c:", shell: "powershell", befehl: "Get-ADUser joerg" }, ]); expect(skript).toBe("# Schritt: Konto anlegen format c:\r\nGet-ADUser joerg\r\n"); expect(skript.split("\r\n").filter((zeile) => zeile !== "")).toHaveLength(2); }); it("faengt auch Steuerzeichen und Leerzeichenketten im Titel", () => { const skript = skriptBauen([{ titel: " Konto\t\tanlegen ", shell: "bash", befehl: "id joerg" }]); expect(skript).toBe("# Schritt: Konto anlegen\nid joerg\n"); }); it("setzt fuer einen Titel, von dem nichts bleibt, einen Ersatz ein", () => { const skript = skriptBauen([{ titel: "\n\t", shell: "bash", befehl: "id joerg" }]); expect(skript).toBe("# Schritt: (ohne Titel)\nid joerg\n"); }); it("laesst Schritte ohne Befehl weg", () => { const skript = skriptBauen([ { titel: "Leer", shell: "bash", befehl: " " }, { titel: "Voll", shell: "bash", befehl: "id joerg" }, ]); expect(skript).toBe("# Schritt: Voll\nid joerg\n"); }); it("liefert bei keinem Schritt und bei nur leeren Befehlen eine leere Zeichenkette", () => { expect(skriptBauen([])).toBe(""); expect(skriptBauen([{ titel: "Leer", shell: "cmd", befehl: "" }])).toBe(""); }); it("haelt die Umbrueche je Shell fest", () => { expect(UMBRUCH.powershell).toBe("\r\n"); expect(UMBRUCH.cmd).toBe("\r\n"); expect(UMBRUCH.bash).toBe("\n"); }); describe.skipIf(!bashDa)("das erzeugte bash-Skript laeuft", () => { it("fuehrt beide Befehle in der Reihenfolge aus und haelt die Kommentare heraus", () => { const skript = skriptBauen([ { titel: "Erster # mit Rauten", shell: "bash", befehl: "printf 'eins'" }, { titel: "Zweiter", shell: "bash", befehl: "printf 'zwei'" }, ]); expect(execFileSync("bash", ["-c", skript], { encoding: "utf8" })).toBe("einszwei"); }); it("fuehrt einen Titel nicht aus, der wie ein Befehl aussieht", () => { const verzeichnis = mkdtempSync(join(tmpdir(), "laufzettel-skript-")); const marke = join(verzeichnis, "ausgefuehrt"); try { const skript = skriptBauen([ { titel: `Anlegen\ntouch ${marke}`, shell: "bash", befehl: "printf 'eins'" }, ]); expect(execFileSync("bash", ["-c", skript], { encoding: "utf8" })).toBe("eins"); expect(existsSync(marke)).toBe(false); } finally { rmSync(verzeichnis, { recursive: true, force: true }); } }); }); });