feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #26.
This commit is contained in:
2026-09-25 13:30:31 +00:00
parent f85bc87324
commit eec5de404a
21 changed files with 4069 additions and 166 deletions
+341
View File
@@ -0,0 +1,341 @@
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<Shell, string> = {
powershell: "\r\n",
bash: "\n",
cmd: "\r\n",
};
/** Wie eine Kommentarzeile beginnt. Cmd kennt kein `#`. */
export const KOMMENTAR_ZEICHEN: Record<Shell, string> = {
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: <Titel>`
* davor (`REM Schritt: <Titel>` 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;
}