feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung
Aus einer Vorlage wird hier der PowerShell-Befehl, den ein Techniker kopiert und
auf einem Domaenencontroller ausfuehrt. Entsprechend dicht ist die Abdeckung:
810 Tests, und die Maskierung wird **ausgefuehrt** geprueft, nicht gelesen.
- Platzhalter aus Geraet, Lauf, Mitarbeiter und Abteilung, dazu die Variablen
der Vorlage; ein unbekannter bleibt sichtbar stehen
- `{{ou}}` ist der Eltern-Container des DN — genau der Pfad fuer New-ADUser
- Namensregeln fuer upn, sam, mail und hostname mit Transliteration, `{v}`,
`{nnn}` und `{kundenkuerzel}`; Kundenregeln ueberlagern die globalen feldweise
- Maskierung je Shell, Skript-Kopie je Abschnitt mit Kommentarzeilen
- `{{generiertes_passwort}}`: 20 Zeichen, bei jeder Anzeige neu, nirgends
gespeichert; im Druck `[Passwort – nicht gespeichert]`
Der unabhaengige Review fand drei Blocker. Der schwerste war eine
Codeausfuehrung: PowerShell liest auch `“`, `”` und `„` als Begrenzer einer
Zeichenkette, die Maskierung kannte nur das ASCII-Zeichen. Ein Abteilungsname
aus Word — Word wandelt `"` von selbst um — reichte. Gefunden hat es der Review
nicht durch Lesen, sondern indem er sich PowerShell in einen Container geholt
und die Markierungsdatei hat entstehen lassen. Der Test dagegen hatte sich still
uebersprungen; er nimmt jetzt selbst das Abbild und bricht in CI ab.
Dazu: zwei verschiedene Passwoerter in einer Antwort (Anzeige und Skript wurden
getrennt gefuellt), eine Vorlagenvariable, die `{{techniker}}` und `{{kunde}}`
ueberschreiben konnte, und die tragende Annahme der Maskierung — dass der
Platzhalter in Anfuehrungszeichen steht —, die niemand geprueft hat.
Schliesst #012
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f85bc87324
commit
b59fbb4645
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PASSWORT_GROSS,
|
||||
PASSWORT_KLEIN,
|
||||
PASSWORT_LAENGE,
|
||||
PASSWORT_SONDER,
|
||||
PASSWORT_ZIFFERN,
|
||||
passwortErzeugen,
|
||||
} from "@/lib/passwort";
|
||||
|
||||
/**
|
||||
* Das generierte Passwort (#012).
|
||||
*
|
||||
* Die Zusage lautet: bei jeder Anzeige neu und nirgends gespeichert. Hier steht
|
||||
* die eine Haelfte davon — dass es wirklich jedes Mal ein anderes ist und dass
|
||||
* es die Anforderungen erfuellt, an denen ein `New-ADUser` sonst scheitert.
|
||||
*
|
||||
* Die andere Haelfte — dass es in keiner Tabelle landet — steht in
|
||||
* `tests/befehle.test.ts` („schreibt beim Fuellen nichts in die Datenbank“). Sie
|
||||
* prueft, was heute pruefbar ist: dass der Weg vom Lauf zum fertigen Befehl
|
||||
* nichts schreibt. `Schrittergebnis` und `Kommentar` gibt es erst mit #013; dort
|
||||
* gehoert derselbe Beweis noch einmal hin, wenn es sie gibt.
|
||||
*/
|
||||
|
||||
const ALLE = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN + PASSWORT_SONDER;
|
||||
|
||||
describe("Erzeugtes Passwort", () => {
|
||||
it("ist 20 Zeichen lang", () => {
|
||||
expect(passwortErzeugen()).toHaveLength(20);
|
||||
expect(PASSWORT_LAENGE).toBe(20);
|
||||
});
|
||||
|
||||
it("benutzt nur Zeichen aus dem vereinbarten Vorrat", () => {
|
||||
for (let versuch = 0; versuch < 200; versuch += 1) {
|
||||
for (const zeichen of passwortErzeugen()) {
|
||||
expect(ALLE.includes(zeichen), `„${zeichen}“ gehört nicht zum Vorrat`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("enthält aus jeder Gruppe mindestens ein Zeichen", () => {
|
||||
// Die Passwortrichtlinie eines Active Directory verlangt ueblicherweise drei
|
||||
// von vier Gruppen. Ein abgewiesenes New-ADUser laesst den Techniker vor dem
|
||||
// Kunden stehen.
|
||||
for (let versuch = 0; versuch < 200; versuch += 1) {
|
||||
const passwort = passwortErzeugen();
|
||||
for (const [name, gruppe] of [
|
||||
["Großbuchstaben", PASSWORT_GROSS],
|
||||
["Kleinbuchstaben", PASSWORT_KLEIN],
|
||||
["Ziffern", PASSWORT_ZIFFERN],
|
||||
["Sonderzeichen", PASSWORT_SONDER],
|
||||
] as const) {
|
||||
expect(
|
||||
[...passwort].some((z) => gruppe.includes(z)),
|
||||
`${name} fehlen in „${passwort}“`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("enthält keine verwechselbaren Zeichen", () => {
|
||||
/*
|
||||
* Das Passwort wird vom Bildschirm abgelesen und getippt – oft von der
|
||||
* Person, der das Konto gehoert. Ein falsch gelesenes Zeichen kostet einen
|
||||
* Anruf.
|
||||
*
|
||||
* Verwechselbar ist immer ein **Paar**: aus jedem Paar fehlt genau einer,
|
||||
* dann ist der andere eindeutig. `B` ist draussen, also darf `8` bleiben;
|
||||
* `O` ist draussen, also darf nichts mit `0` verwechselt werden — `0` ist
|
||||
* trotzdem weg, weil es mit `o` zusammenfaellt.
|
||||
*/
|
||||
for (let versuch = 0; versuch < 200; versuch += 1) {
|
||||
expect(passwortErzeugen()).not.toMatch(/[IlB1|O0]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("lässt aus jedem verwechselbaren Paar genau einen übrig", () => {
|
||||
// Haelt die Begruendung oben fest: `8` ohne `B` ist eindeutig, `1` ohne `l`
|
||||
// und `I` waere es auch — es ist trotzdem draussen, weil `|` in manchen
|
||||
// Schriften gleich aussieht.
|
||||
const vorrat = PASSWORT_GROSS + PASSWORT_KLEIN + PASSWORT_ZIFFERN;
|
||||
for (const paar of ["I1", "l1", "O0", "o0", "B8", "Il"]) {
|
||||
const drin = [...paar].filter((zeichen) => vorrat.includes(zeichen));
|
||||
expect(drin.length, `„${paar}“ ist beides im Vorrat`).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("enthält nichts, was einen Befehl oder eine CSV-Zelle zerbricht", () => {
|
||||
// Die Maskierung faengt das ab – aber ein Passwort, das nur wegen korrekter
|
||||
// Maskierung funktioniert, ist eine Wette auf jeden kuenftigen Aufrufer.
|
||||
for (let versuch = 0; versuch < 200; versuch += 1) {
|
||||
expect(passwortErzeugen()).not.toMatch(/["'$`\\;&|<>%,\n\r]/u);
|
||||
}
|
||||
});
|
||||
|
||||
it("liefert bei zwei Aufrufen verschiedene Passwörter", () => {
|
||||
// Genau das Akzeptanzkriterium: ein zweiter Aufruf ist ein **anderes**
|
||||
// Passwort, nicht dasselbe noch einmal.
|
||||
const gesehen = new Set<string>();
|
||||
for (let versuch = 0; versuch < 500; versuch += 1) {
|
||||
gesehen.add(passwortErzeugen());
|
||||
}
|
||||
expect(gesehen.size).toBe(500);
|
||||
});
|
||||
|
||||
it("verteilt die Pflichtzeichen über die ganze Länge", () => {
|
||||
// Ohne Mischen staenden Gross, Klein, Ziffer und Sonderzeichen immer an den
|
||||
// ersten vier Stellen — erkennbar, und damit vier Zeichen weniger Zufall.
|
||||
const stellen = new Set<number>();
|
||||
for (let versuch = 0; versuch < 300; versuch += 1) {
|
||||
const passwort = passwortErzeugen();
|
||||
const stelle = [...passwort].findIndex((z) => PASSWORT_SONDER.includes(z));
|
||||
stellen.add(stelle);
|
||||
}
|
||||
expect(stellen.size).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("weist eine Länge ab, die nicht alle vier Gruppen fassen kann", () => {
|
||||
expect(() => passwortErzeugen(3)).toThrow();
|
||||
expect(passwortErzeugen(4)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("fängt nie mit einem Sonderzeichen an", () => {
|
||||
// `= + - @` am Anfang einer Zelle liest eine Tabellenkalkulation als Formel,
|
||||
// und ein Passwort geht seinen Weg oft ueber Zwischenablage und Tabelle.
|
||||
for (let versuch = 0; versuch < 500; versuch += 1) {
|
||||
expect(PASSWORT_SONDER.includes(passwortErzeugen().charAt(0))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("enthält kein Ausrufezeichen", () => {
|
||||
// Bei eingeschalteter verzoegerter Ersetzung frisst cmd.exe es still – das
|
||||
// Konto bekaeme ein anderes Passwort als angezeigt.
|
||||
for (let versuch = 0; versuch < 200; versuch += 1) {
|
||||
expect(passwortErzeugen()).not.toContain("!");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user