Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * Der Webprozess laeuft unprivilegiert. Alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""Prueft das Repository selbst -- die Fehlerklassen, die erst beim Ausrollen auffallen.
|
|
|
|
Der Vorgaenger hat sich hier zweimal die Finger verbrannt: CRLF-Zeilenenden aus
|
|
einer Windows-Buildmaschine machten Shell-Skripte auf dem Ziel unbrauchbar
|
|
("bad interpreter"), und Schluesseldateien landeten versehentlich im Repo.
|
|
Beides faellt hier auf, bevor ein Paket gebaut wird.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
SKIP_DIRS = {
|
|
".git",
|
|
".venv",
|
|
"__pycache__",
|
|
"instance",
|
|
".pytest_cache",
|
|
".ruff_cache",
|
|
"node_modules",
|
|
}
|
|
TEXT_SUFFIXES = {
|
|
".py", ".sh", ".html", ".css", ".js", ".json", ".toml", ".md",
|
|
".yaml", ".yml", ".cfg", ".ini", ".txt", ".service", ".conf",
|
|
}
|
|
#: Dateien ohne Endung, die trotzdem Text sind.
|
|
EXTENSIONLESS_TEXT = {"tesm-helper", "tesm", "tesm-license"}
|
|
|
|
SECRET_NAMES = {
|
|
"data.keys",
|
|
"secret.key",
|
|
"license.json",
|
|
"license_key.json",
|
|
"master_signing_key.json",
|
|
"fernet.key",
|
|
"known_hosts",
|
|
}
|
|
SECRET_SUFFIXES = {".db", ".db-wal", ".db-shm", ".pem", ".p12", ".pfx"}
|
|
|
|
|
|
def _repo_files() -> list[Path]:
|
|
files: list[Path] = []
|
|
for path in REPO_ROOT.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
if any(part in SKIP_DIRS for part in path.relative_to(REPO_ROOT).parts):
|
|
continue
|
|
files.append(path)
|
|
return files
|
|
|
|
|
|
def _text_files() -> list[Path]:
|
|
return [
|
|
path
|
|
for path in _repo_files()
|
|
if path.suffix in TEXT_SUFFIXES or path.name in EXTENSIONLESS_TEXT
|
|
]
|
|
|
|
|
|
def test_no_crlf_line_endings():
|
|
"""CRLF im Paket macht Shell-Skripte auf dem Linux-Ziel unbrauchbar."""
|
|
offenders = [
|
|
str(path.relative_to(REPO_ROOT))
|
|
for path in _text_files()
|
|
if b"\r\n" in path.read_bytes()
|
|
]
|
|
assert not offenders, (
|
|
"Diese Dateien haben CRLF-Zeilenenden und wuerden auf dem Zielsystem brechen:\n "
|
|
+ "\n ".join(offenders)
|
|
)
|
|
|
|
|
|
def test_no_secrets_committed():
|
|
"""Schluessel, Datenbanken und Lizenzdateien gehoeren nie ins Repo."""
|
|
offenders = [
|
|
str(path.relative_to(REPO_ROOT))
|
|
for path in _repo_files()
|
|
if path.name in SECRET_NAMES or path.suffix in SECRET_SUFFIXES
|
|
]
|
|
assert not offenders, "Geheimnisse im Repository:\n " + "\n ".join(offenders)
|
|
|
|
|
|
def test_shell_scripts_have_shebang_and_strict_mode():
|
|
scripts = [path for path in _repo_files() if path.suffix == ".sh" or path.name == "tesm-helper"]
|
|
assert scripts, "Es wurden keine Shell-Skripte gefunden -- Test prueft ins Leere."
|
|
for script in scripts:
|
|
text = script.read_text(encoding="utf-8")
|
|
assert text.startswith("#!"), f"{script.name}: Shebang fehlt"
|
|
assert "set -euo pipefail" in text, f"{script.name}: 'set -euo pipefail' fehlt"
|
|
|
|
|
|
def test_helper_validates_every_verb():
|
|
"""Jedes Verb des privilegierten Helfers muss im case-Block auftauchen.
|
|
|
|
Der Helfer ist die einzige Stelle mit Root-Rechten. Ein Verb, das die
|
|
Anwendung kennt, der Helfer aber nicht, waere entweder tot -- oder,
|
|
schlimmer, ein Hinweis auf eine ungeprueft durchgereichte Aktion.
|
|
"""
|
|
from tesm_core.sysops import HELPER_VERBS
|
|
|
|
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
# case-Muster koennen mehrere Verben mit | buendeln.
|
|
patterns: set[str] = set()
|
|
for line in helper.splitlines():
|
|
match = re.match(r"^\s{2,4}([a-z|-]+)\)\s*$", line)
|
|
if match:
|
|
patterns.update(match.group(1).split("|"))
|
|
missing = [verb for verb in HELPER_VERBS if verb not in patterns]
|
|
assert not missing, f"Verben fehlen im Helfer: {missing}"
|
|
|
|
|
|
def test_helper_has_no_unquoted_argument_passthrough():
|
|
"""Der Helfer darf Argumente nie ungeprueft an eine Shell weiterreichen."""
|
|
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
for forbidden in ("eval ", "bash -c", "sh -c"):
|
|
assert forbidden not in helper, f"Der Helfer enthaelt {forbidden!r}"
|
|
|
|
|
|
def test_helper_blocks_path_traversal_everywhere():
|
|
"""Jede Pfadpruefung im Helfer muss ".." abweisen.
|
|
|
|
Ohne diese Sperre laesst sich eine Positivliste ueber den Basisnamen
|
|
unterlaufen: ``/etc/nginx/sites-available/../../../root/tesm`` hat den
|
|
zulaessigen Basisnamen ``tesm``, liest aber ``/root/tesm``.
|
|
"""
|
|
helper = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
checks = re.findall(
|
|
r"^check_\w*(?:path|target|webroot)\w*\(\) \{(.*?)^\}", helper, re.S | re.M
|
|
)
|
|
assert checks, "Es wurden keine Pfadpruefungen gefunden -- Test prueft ins Leere."
|
|
for body in checks:
|
|
assert '*".."*' in body, "Pfadpruefung ohne ..-Sperre: " + body
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("bash") is None, reason="bash nicht verfuegbar")
|
|
def test_helper_accepts_instances_but_no_invented_keys():
|
|
"""Der Namensraum einer Instanz ist eng begrenzt.
|
|
|
|
Er landet ungefiltert in Pfaden wie ``/etc/nginx/sites-available/<key>``.
|
|
"""
|
|
script = (REPO_ROOT / "deploy" / "tesm-helper").read_text(encoding="utf-8")
|
|
start = script.index("check_app_key() {")
|
|
end = script.index(chr(10) + "}", start) + 2
|
|
prelude = 'APP_KEYS_ALLOWED=("tesm" "tesm-license")' + chr(10) + "fail() { exit 1; }" + chr(10)
|
|
snippet = prelude + script[start:end]
|
|
|
|
def accepted(key: str) -> bool:
|
|
command = snippet + chr(10) + 'check_app_key "' + key + '"'
|
|
return subprocess.run(["bash", "-c", command], capture_output=True).returncode == 0
|
|
|
|
for good in ("tesm", "tesm-license", "tesm-opus", "tesm-license-opus", "tesm-test2"):
|
|
assert accepted(good), good
|
|
for bad in ("", "foo", "tesm-", "tesm-../etc", "../tesm", "tesm-GROSS", "tesm-" + "x" * 20):
|
|
assert not accepted(bad), bad
|
|
|
|
|
|
|
|
def test_systemd_units_are_hardened():
|
|
units = list((REPO_ROOT / "deploy" / "systemd").glob("*.service"))
|
|
assert units
|
|
for unit in units:
|
|
text = unit.read_text(encoding="utf-8")
|
|
assert "User=root" not in text, f"{unit.name} laeuft als root"
|
|
# ProtectSystem=full, nicht strict: strict sperrt auch den privilegierten
|
|
# Helfer aus /etc aus -- gemessen, siehe test_core_security.py.
|
|
for directive in ("ProtectSystem=full", "PrivateTmp=yes"):
|
|
assert directive in text, f"{unit.name}: {directive} fehlt"
|
|
# NoNewPrivileges waere hier kontraproduktiv: es schaltet setuid ab und
|
|
# damit sudo -- der Dienst koennte den privilegierten Helfer nicht mehr
|
|
# aufrufen, und jede Systemaktion aus der Oberflaeche waere tot. Genau
|
|
# so war es, und die Diagnoseseite meldete trotzdem "Helfer erreichbar".
|
|
assert "NoNewPrivileges=yes" not in text, (
|
|
f"{unit.name}: NoNewPrivileges=yes verhindert sudo und damit den Helfer."
|
|
)
|
|
|
|
|
|
def test_sudoers_grants_only_the_helper():
|
|
for name in ("tesm", "tesm-license"):
|
|
raw = (REPO_ROOT / "deploy" / "sudoers" / name).read_text(encoding="utf-8")
|
|
# Kommentare erklaeren, was bewusst *nicht* getan wird -- sie duerfen den
|
|
# Test nicht ausloesen.
|
|
rules = [line for line in raw.splitlines() if line.strip() and not line.startswith("#")]
|
|
body = "\n".join(rules)
|
|
assert "NOPASSWD: ALL" not in body, f"{name}: zu weite sudo-Regel"
|
|
assert "/usr/local/lib/tesm/tesm-helper" in body
|
|
for line in rules:
|
|
if "NOPASSWD" in line:
|
|
assert line.strip().endswith("/usr/local/lib/tesm/tesm-helper"), (
|
|
f"{name}: NOPASSWD-Regel erlaubt mehr als den Helfer: {line}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("app", ["tesm", "tesm-license"])
|
|
def test_nginx_template_serves_static_from_app_root(app: str):
|
|
"""Der alias-Pfad ist genau der Fehler, der im Vorgaenger jahrelang ueberlebte."""
|
|
text = (REPO_ROOT / "deploy" / "nginx" / f"{app}.conf").read_text(encoding="utf-8")
|
|
assert f"alias /srv/{app}/static/;" in text
|
|
assert f"alias /srv/{app}/static-core/;" in text
|
|
|
|
|
|
def test_documentation_referenced_by_the_units_exists():
|
|
"""``Documentation=`` in der Unit muss auf eine Datei zeigen, die es gibt.
|
|
|
|
Sonst laeuft ``systemctl help tesm`` ins Leere -- und schlimmer: der
|
|
Verweis suggeriert eine Anleitung, die niemand geschrieben hat.
|
|
"""
|
|
for unit in (REPO_ROOT / "deploy" / "systemd").glob("*.service"):
|
|
for line in unit.read_text(encoding="utf-8").splitlines():
|
|
if not line.startswith("Documentation=file:"):
|
|
continue
|
|
target = line.split("file:", 1)[1].strip()
|
|
# /srv/<app>/docs/X -> docs/X im Repository
|
|
relative = target.split("/docs/", 1)[1]
|
|
assert (REPO_ROOT / "docs" / relative).is_file(), (
|
|
f"{unit.name} verweist auf {target}, docs/{relative} fehlt"
|
|
)
|
|
|
|
|
|
def test_readme_links_point_to_existing_files():
|
|
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
|
|
for target in re.findall(r"\]\((docs/[^)#]+)\)", readme):
|
|
assert (REPO_ROOT / target).is_file(), f"README verweist auf {target}"
|
|
|
|
|
|
def _templates() -> list[Path]:
|
|
return [
|
|
path
|
|
for path in _repo_files()
|
|
if path.suffix == ".html" and "templates" in path.parts
|
|
]
|
|
|
|
|
|
def test_no_html_entities_inside_escaped_macro_calls():
|
|
"""Makros escapen ihren Text -- Entities darin erscheinen woertlich.
|
|
|
|
Auf der Wartungsseite stand deshalb ``„Linux-Client“`` als
|
|
sichtbarer Text. Der Fehler ist unauffaellig, weil dieselbe Schreibweise
|
|
im uebrigen Vorlagentext voellig richtig ist.
|
|
"""
|
|
# Genau bis zum schliessenden "}}" des Aufrufs -- ein greedy Ausdruck
|
|
# verschluckte sonst den nachfolgenden Seiteninhalt und meldete dessen
|
|
# Tabellenmarkup als Treffer.
|
|
pattern = re.compile(
|
|
r"\{\{\s*(?:alert|empty_state|pill|stat)\((?:[^{}]|\{[^{}]*\})*?\)\s*"
|
|
r"(?:\|\s*safe\s*)?\}\}",
|
|
re.S,
|
|
)
|
|
offenders: list[str] = []
|
|
for path in _templates():
|
|
for match in pattern.finditer(path.read_text(encoding="utf-8")):
|
|
call = match.group(0)
|
|
# Frueher stand hier eine Ausnahme fuer "| safe". Sie war falsch:
|
|
# das Filter wirkt auf das *Ergebnis* des Makros, escaped wurde aber
|
|
# schon beim Einsetzen des Arguments. Auf der Geraeteseite stand
|
|
# deshalb woertlich "<em>und</em>" im Text.
|
|
if re.search(r"&[a-zA-Z]{2,10};|</?[a-z]{1,10}>", call):
|
|
offenders.append(
|
|
f"{path.relative_to(REPO_ROOT)}: {' '.join(call.split())[:100]}"
|
|
)
|
|
assert not offenders, (
|
|
"HTML im escapten Makrotext -- das erscheint woertlich auf der Seite:\n "
|
|
+ "\n ".join(offenders)
|
|
)
|
|
|
|
|
|
def test_refresh_regions_never_point_at_a_whole_page():
|
|
"""``data-refresh-url`` schreibt die Antwort in ``innerHTML``.
|
|
|
|
Zeigt die URL auf eine vollstaendige Seite, landet die ganze Seite in
|
|
einem Ausschnitt -- bei jedem Durchlauf eine Ebene tiefer. Genau das ist
|
|
auf der Wartungsseite passiert: die Karte "Auftraege" holte sich
|
|
``maintenance.overview``, also sich selbst.
|
|
|
|
Erlaubt sind nur Endpunkte, deren Name sie als Ausschnitt ausweist.
|
|
"""
|
|
fragment_markers = ("fragment", "tiles", "output", "status", "partial", "ausschnitt")
|
|
pattern = re.compile(r"data-refresh-url=\"\{\{\s*url_for\('([^']+)'")
|
|
found = 0
|
|
for path in _templates():
|
|
for match in pattern.finditer(path.read_text(encoding="utf-8")):
|
|
endpoint = match.group(1)
|
|
found += 1
|
|
assert any(marker in endpoint.lower() for marker in fragment_markers), (
|
|
f"{path.relative_to(REPO_ROOT)}: data-refresh-url zeigt auf {endpoint!r} -- "
|
|
"das sieht nach einer ganzen Seite aus, nicht nach einem Ausschnitt."
|
|
)
|
|
assert found, "Keine Aktualisierungsbereiche gefunden -- Test prueft ins Leere."
|
|
|
|
|
|
def test_refresh_loop_treats_zero_as_off():
|
|
"""``Number(x || 15000)`` macht aus einer 0 klammheimlich 15000."""
|
|
script = (
|
|
REPO_ROOT
|
|
/ "packages"
|
|
/ "tesm-core"
|
|
/ "src"
|
|
/ "tesm_core"
|
|
/ "static"
|
|
/ "tesm_core"
|
|
/ "js"
|
|
/ "app.js"
|
|
).read_text(encoding="utf-8")
|
|
assert "refreshInterval || " not in script
|
|
assert "interval <= 0" in script, "Ein Intervall von 0 muss die Aktualisierung abschalten."
|
|
|
|
|
|
def test_licensing_package_is_shared_not_copied():
|
|
"""Es darf keine zweite Kopie des Lizenzprotokolls geben.
|
|
|
|
Genau diese Doppelung -- zwei byte-identisch zu haltende ``licensing.py`` --
|
|
war im Vorgaenger die gefaehrlichste Invariante.
|
|
"""
|
|
copies = [
|
|
str(path.relative_to(REPO_ROOT))
|
|
for path in _repo_files()
|
|
if path.name == "licensing.py"
|
|
]
|
|
assert not copies, f"Kopien des Lizenzmoduls gefunden: {copies}"
|