395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
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 });
|
||
}
|
||
});
|
||
});
|
||
});
|