import { SHELLS } from "@/lib/vorlagen-format"; /** * Maskieren von Werten fuer eine Shell und die Skript-Kopie (#012). * * Hier endet der Weg eines Werts aus dem Verzeichnis oder aus einem * Eingabefeld: als Teil eines Befehls, den ein Techniker kopiert und auf einem * Domaenencontroller ausfuehrt. Ein Wert, der die Anfuehrungszeichen des Befehls * sprengt, fuehrt dort fremden Code aus — mit den Rechten, die der Techniker * gerade hat, und das sind die hoechsten im Haus. * * **Die tragende Annahme, ausgesprochen:** `wertMaskieren` maskiert fuer die * Einbettung **innerhalb** der Anfuehrungszeichen, die schon in der Vorlage * stehen — bei PowerShell und Cmd doppelte, bei Bash einfache: * * ``` * New-ADUser -Name "{{vorname}} {{nachname}}" → ... -Name "Jörg Müller" * useradd -c '{{mitarbeiter}}' → ... -c 'O'\''Brien' * ``` * * Diese Funktion setzt **keine** Anfuehrungszeichen dazu, weil sie sonst die * schon vorhandenen verdoppeln wuerde. Steht der Platzhalter in der Vorlage * nackt da (`-Name {{vorname}}`), kann keine Maskierung der Welt daraus ein * einzelnes Argument machen; dann schuetzt nur, wer die Vorlage schreibt. Das ist * eine Annahme und keine Zusage, und sie steht hier, damit niemand sie fuer eine * Zusage haelt. */ export type Shell = (typeof SHELLS)[number]; export class ShellFehler extends Error { constructor(nachricht: string) { super(nachricht); this.name = "ShellFehler"; } } /** * Zeilenumbruch je Shell. * * PowerShell und Cmd leben in der CRLF-Welt: eine `.cmd`-Datei mit reinen * Zeilenvorschueben bringt `cmd.exe` bei Sprungmarken zum Stolpern. Bash lebt in * der LF-Welt, und zwar unversoehnlich: ein Wagenruecklauf am Zeilenende landet * als Zeichen im letzten Argument und macht aus `fi` ein `fi\r`. */ export const UMBRUCH: Record = { powershell: "\r\n", bash: "\n", cmd: "\r\n", }; /** Wie eine Kommentarzeile beginnt. Cmd kennt kein `#`. */ export const KOMMENTAR_ZEICHEN: Record = { powershell: "#", bash: "#", cmd: "REM", }; /** * Zeichen, die Cmd innerhalb doppelter Anfuehrungszeichen nicht tragen kann. * * Siehe `cmdMaskieren` — diese vier werden abgelehnt, statt sie unsicher * durchzulassen. */ const CMD_UNMOEGLICH: readonly (readonly [RegExp, string])[] = [ [/"/, "ein doppeltes Anführungszeichen"], [/\r/, "einen Wagenrücklauf"], [/\n/, "einen Zeilenumbruch"], [/\u0000/, "ein Null-Byte"], ]; /** * Zeichen, die PowerShell als doppeltes Anfuehrungszeichen liest. * * Nicht nur `U+0022`: `about_Quoting_Rules` nennt ausdruecklich auch die * typografischen Varianten. Gegen PowerShell 7 nachgemessen — `“`, `”` und `„` * **beenden eine `"…"`-Zeichenkette**, alles danach ist Code. * * Das ist kein erfundener Angriff: `„` ist das deutsche Anfuehrungszeichen, und * Word wie Outlook wandeln ein getipptes `"` von selbst in `“`/`”`. Ein * Abteilungsname, den jemand aus Word in ein Formular kopiert, reicht. */ const PS_DOPPELTE_ANFUEHRUNG = /[\u201C\u201D\u201E]/gu; /** * Dieselbe Familie fuer das einfache Anfuehrungszeichen. * * Innerhalb von `"…"` sind sie harmlos und werden **nicht** verdoppelt — das * verfaelschte jeden Namen wie „O’Neill“. Angeglichen werden sie trotzdem: * benutzt eine Vorlage die andere Form (`'{{vorname}}'`), beenden sie dort die * Zeichenkette (gemessen gegen PowerShell 7). Dass ein gewoehnliches `'` dieselbe * Vorlage genauso bricht, faengt `platzhalterAusserhalbAnfuehrungszeichen` ab. */ const PS_EINFACHE_ANFUEHRUNG = /[\u2018\u2019\u201A\u201B]/gu; /** * Maskiert fuer eine PowerShell-Zeichenkette in doppelten Anfuehrungszeichen. * * Innerhalb von `"…"` sind genau drei Zeichen besonders, und alle drei werden * hier entschaerft: * * - `` ` `` ist das Fluchtzeichen und wird verdoppelt. Zuerst, sonst wuerden die * Fluchtzeichen der naechsten Schritte gleich wieder mitverdoppelt. * - `"` beendet die Zeichenkette und wird verdoppelt (`""` ist innerhalb von * `"…"` das literale Anfuehrungszeichen). * - `$` fuehrt die Ersetzung ein. Das schliesst `$(…)` ein, also die Ausfuehrung: * aus `$(rm -rf /)` wird `` `$(rm -rf /) `` und damit sichtbarer Text statt * eines Unterausdrucks. * * **Typografische Anfuehrungszeichen werden vorher auf ihre ASCII-Form * gebracht** und danach wie diese behandelt. Das aendert das Zeichen — aus `”` * wird `"` —, und das ist Absicht: PowerShell liest es ohnehin als Begrenzer, * es laesst sich also gar nicht als Text durchreichen. Lieber ein Zeichen, das * anders aussieht als gedacht, als ein Befehl, der etwas anderes tut als er * liest. * * Zeilenumbrueche werden zu `` `r `` und `` `n ``. Innerhalb von `"…"` waeren sie * auch unmaskiert unschaedlich — die Zeichenkette laeuft ueber Zeilen weiter —, * aber der Wert bleibt so in **einer** Zeile, und was in einer Zeile steht, * ueberlebt jedes Kopieren, jedes Einfuegen in eine Konsole und jede * Skript-Kopie, die Zeilen einzeln behandelt. */ function powershellMaskieren(wert: string): string { return wert .replace(PS_DOPPELTE_ANFUEHRUNG, '"') .replace(PS_EINFACHE_ANFUEHRUNG, "'") .replaceAll("`", "``") .replaceAll('"', '""') .replaceAll("$", "`$") .replaceAll("\r", "`r") .replaceAll("\n", "`n"); } /** * Maskiert fuer eine Bash-Zeichenkette in einfachen Anfuehrungszeichen. * * In `'…'` ist **kein** Zeichen besonders ausser dem einfachen * Anfuehrungszeichen selbst — kein `$`, kein Backtick, kein `\`, kein * Zeilenumbruch. Also genuegt der bekannte Kunstgriff: das Anfuehrungszeichen * schliesst die Zeichenkette, setzt ein maskiertes `\'` daneben und oeffnet * wieder. Fuer die Shell bleibt das ein zusammenhaengendes Wort. */ function bashMaskieren(wert: string): string { /* * Zeilenumbrueche bekommen eine eigene Behandlung: `'…'` traegt sie zwar * woertlich, aber `skriptBauen` bringt danach alle Umbrueche des fertigen * Befehls auf die Form der Shell — und kann dabei nicht wissen, welcher zum * Befehl gehoert und welcher aus einem Wert stammt. Aus einem `\r` im Wert * wurde so ein `\n`; bei CRLF-Werten aus CSV oder LDAP der Normalfall. * * `'…'$'\r''…'` ist Bashs eigene Schreibweise dafuer: die Zeichenkette wird * geschlossen, das Zeichen als Fluchtfolge angehaengt und wieder geoeffnet. * Fuer die Shell bleibt es ein Wort, und im fertigen Befehl steht kein * wirklicher Umbruch mehr. */ return ( wert .replaceAll("'", `'\\''`) /* * Ersatz**funktionen**, keine Zeichenketten: `$'` ist in `String.replace` * ein Rueckverweis auf alles hinter dem Treffer. Aus `erste\nzweite` wurde * damit `erste'zweite\n''zweite` — ein Befehl, der nicht mehr zugeht. * Derselbe Fehler steckte in `mitBeispielwerten`, gefunden im Review zu #010. */ .replaceAll("\r", () => `'$'\\r''`) .replaceAll("\n", () => `'$'\\n''`) ); } /** * Maskiert fuer eine Cmd-Zeichenkette in doppelten Anfuehrungszeichen — soweit das geht. * * **Was hier nicht geht, und zwar grundsaetzlich:** * * - Ein `"` im Wert laesst sich nicht maskieren. `\"` ist keine Regel von * `cmd.exe`, sondern eine Verabredung der C-Laufzeit des aufgerufenen * Programms; `""` deuten verschiedene Programme verschieden, und `^"` wirkt * innerhalb von Anfuehrungszeichen nicht. Ein `"` beendet das Argument, und * alles danach liest `cmd.exe` als Befehl — inklusive `&`. Deshalb wird ein * solcher Wert **abgelehnt**, mit CR, LF und dem Null-Byte zusammen, die eine * Cmd-Befehlszeile ebenfalls nicht tragen kann. * - Ein `%` bleibt stehen und wird von `cmd.exe` **ersetzt**, wenn er einen * Variablennamen einschliesst: aus `%PATH%` wird der Suchpfad. Innerhalb von * Anfuehrungszeichen aendert das nichts daran. Verdoppeln waere nur in einer * Batch-Datei richtig und in einer interaktiven Eingabe falsch, und dieselbe * Zeichenkette dient hier beidem — es gibt also keine Maskierung, die in * beiden Faellen stimmt. * * **Das ist nicht harmlos**, auch wenn Cmd keine Befehlsersetzung kennt: in * einer Batch-Datei wird `%VAR%` **vor** der Zerlegung in Befehl und * Trennzeichen ersetzt. Enthaelt die Variable ein `&` oder ein `"`, wird das * danach als Trennzeichen bzw. Anfuehrungszeichen gelesen — genau deshalb gibt * es die verzoegerte Ersetzung `!VAR!` ueberhaupt. Wer einen Wert mit `%` * erwartet, stellt den Schritt besser auf PowerShell um. * - Ein `!` bleibt stehen. Er wird nur ersetzt, wenn die verzoegerte Ersetzung * eingeschaltet ist (`setlocal enabledelayedexpansion`). Die Skript-Kopie * dieses Moduls schaltet sie nicht ein; eine fremde Umgebung kann es. * * Harmlos sind dagegen `&`, `|`, `<`, `>` und `^`: `cmd.exe` behandelt * Anfuehrungszeichen vor den Trennzeichen, innerhalb von `"…"` sind sie * gewoehnliche Zeichen. Genau deshalb ist das `"` das einzige, was zaehlt — und * genau deshalb wird es abgelehnt. */ function cmdMaskieren(wert: string): string { for (const [muster, benennung] of CMD_UNMOEGLICH) { if (muster.test(wert)) { throw new ShellFehler( `Der Wert enthält ${benennung}. In einem Cmd-Befehl lässt sich das nicht sicher maskieren; ` + "bitte den Schritt auf PowerShell umstellen oder den Wert bereinigen.", ); } } return wert; } /** * Maskiert einen Wert fuer die angegebene Shell. * * Erwartet, dass der Platzhalter in der Vorlage in Anfuehrungszeichen steht — * doppelte bei `powershell` und `cmd`, einfache bei `bash`. Siehe die Anmerkung * am Kopf dieser Datei; sie ist die einzige Annahme, die dieses Modul nicht * selbst pruefen kann. * * Wirft `ShellFehler`, wenn ein Wert in einem Cmd-Befehl nicht sicher * darstellbar ist. Ein Fehler an der Stelle ist gewollt: der Techniker sieht * dann, dass hier etwas nicht geht, statt einen Befehl zu kopieren, der etwas * anderes tut als er liest. */ export function wertMaskieren(wert: string, shell: Shell): string { switch (shell) { case "powershell": return powershellMaskieren(wert); case "bash": return bashMaskieren(wert); case "cmd": return cmdMaskieren(wert); } } /** * Platzhalter, die **nicht** zwischen den erwarteten Anfuehrungszeichen stehen. * * Die ganze Maskierung ruht auf einer Annahme: `{{name}}` steht in der Vorlage * in Anfuehrungszeichen — doppelten bei `powershell` und `cmd`, einfachen bei * `bash`. Steht er nackt da (`Write-Host {{vorname}}`), kann keine Maskierung der * Welt daraus noch **ein** Argument machen: ein Leerzeichen im Wert zerlegt ihn, * ein `;` beendet den Befehl. Bis zum Review von #012 hat das niemand geprueft — * die Annahme stand als Kommentar da und wurde nirgends eingeloest. * * Geprueft wird durch schlichtes Durchzaehlen: ein Anfuehrungszeichen oeffnet, * das naechste gleicher Art schliesst. Das ist grob — Fluchtfolgen innerhalb * einer Zeichenkette kann es nicht auseinanderhalten — aber es faellt in die * **sichere** Richtung: im Zweifel meldet es einen Platzhalter, der in Ordnung * ist, statt einen durchzulassen, der es nicht ist. */ export function platzhalterAusserhalbAnfuehrungszeichen(befehl: string, shell: Shell): readonly string[] { const zeichen = shell === "bash" ? "'" : '"'; const gefunden: string[] = []; let offen = false; for (let pos = 0; pos < befehl.length; pos += 1) { if (befehl.charAt(pos) === zeichen) { offen = !offen; continue; } if (befehl.startsWith("{{", pos)) { const ende = befehl.indexOf("}}", pos); if (ende < 0) break; const name = befehl.slice(pos + 2, ende).trim(); if (!offen && /^[a-z][a-z0-9_]*$/u.test(name)) gefunden.push(name); pos = ende + 1; } } return gefunden; } export type SkriptSchritt = { readonly titel: string; readonly shell: Shell; readonly befehl: string; }; /** * Macht aus einem Titel eine Kommentarzeile, die nicht aufbrechen kann. * * Ein Titel mit Zeilenumbruch schiebt seine zweite Haelfte aus dem Kommentar * heraus und laesst sie als Befehl stehen — aus einem Titel „Konto anlegen“ mit * angehaengtem Umbruch und `format c:` wuerde eine ausfuehrbare Zeile. Alle * Steuerzeichen werden deshalb zu Leerzeichen und mehrere Leerzeichen zu einem. */ function kommentarTitel(titel: string): string { const eineZeile = titel .replace(/[\u0000-\u001F\u007F]+/g, " ") .replace(/\s{2,}/g, " ") .trim(); return eineZeile || "(ohne Titel)"; } /** * Der Umbruch fuer ein ganzes Skript. * * Bei einheitlicher Shell der ihre. Eine gemischte Liste laesst sich ohnehin von * keinem Interpreter als Ganzes ausfuehren — dort gibt es keine richtige Wahl, * nur eine weniger schaedliche: LF, weil ein Wagenruecklauf jede Bash-Zeile * sicher beschaedigt, waehrend PowerShell LF ohne weiteres liest. Fuer Cmd bleibt * es ein Zugestaendnis. */ function umbruchFuer(schritte: readonly SkriptSchritt[]): string { const ersteShell = schritte[0]?.shell; if (ersteShell === undefined) return UMBRUCH.bash; return schritte.every((schritt) => schritt.shell === ersteShell) ? UMBRUCH[ersteShell] : "\n"; } /** * Baut aus den Befehlsschritten eines Abschnitts ein Skript zum Kopieren. * * Alle Befehle in der uebergebenen Reihenfolge, je mit `# Schritt: ` * davor (`REM Schritt: ` bei Cmd), getrennt durch eine Leerzeile. Die * Umbrueche **innerhalb** eines mehrzeiligen Befehls werden auf denselben Umbruch * gebracht: eine CRLF-Datei mit einzelnen Zeilenvorschuechen darin ist keine * CRLF-Datei. * * Schritte ohne Befehl fallen heraus: eine Kommentarzeile mit einer Leerzeile * darunter und nichts dazwischen hilft niemandem. * * Das Skript endet mit einem Umbruch, damit die letzte Zeile auch dann * ausgefuehrt wird, wenn sie als Datei gespeichert wird. */ export function skriptBauen(schritte: readonly SkriptSchritt[]): string { const mitBefehl = schritte.filter((schritt) => schritt.befehl.trim() !== ""); if (mitBefehl.length === 0) return ""; const umbruch = umbruchFuer(mitBefehl); const bloecke = mitBefehl.map((schritt) => { const kopf = `${KOMMENTAR_ZEICHEN[schritt.shell]} Schritt: ${kommentarTitel(schritt.titel)}`; const befehl = schritt.befehl.trim().replace(/\r\n|\r|\n/g, umbruch); return kopf + umbruch + befehl; }); return bloecke.join(umbruch + umbruch) + umbruch; }