feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
This commit was merged in pull request #26.
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
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", () => {
|
||||
it("kommt als genau ein Argument an und fuehrt nichts aus", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user