diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b9f5d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ +static/js/vendor/*.js +static/js/vendor/*.css +node_modules/ +*.db +*.db-wal +*.db-shm +ansible/inventory/production.ini +ansible/inventory/group_vars/vault.yml +ansible/inventory/group_vars/jumphost_with_nginx.yml +ansible/inventory/group_vars/jumphost_no_nginx.yml diff --git a/Pentest_Report.md b/Pentest_Report.md new file mode 100644 index 0000000..1b21e2e --- /dev/null +++ b/Pentest_Report.md @@ -0,0 +1,164 @@ +# Sicherheitstest-Bericht: Jumphost Gateway + +**Datum:** 19.08.2026  |  **Version des Testobjekts:** Implementierung v0.2  |  **Tester:** Claude (im Auftrag von Midas) + +## 1. Scope und Methodik + +### 1.1 Was tatsächlich getestet wurde + +Diese Sitzung lief in einer isolierten Cloud-Sandbox ohne Zugriff auf ein +reales Netzwerk, echte SSH-/RDP-Zielsysteme oder eine laufende `guacd`- +Instanz. Der Test ist daher ein **Anwendungssicherheitstest (SAST + gezielter +DAST) gegen den Quellcode und die lokal laufende Anwendung**, kein +vollständiger Infrastruktur-/Netzwerk-Penetrationstest. Konkret durchgeführt: + +1. **Statische Analyse (SAST):** `bandit` gegen den gesamten Python-Code + (`app/`, `scripts/`), `pip-audit` gegen alle Produktiv- und + Dev-Abhängigkeiten. +2. **Dynamischer Sicherheitstest (DAST, White-/Grey-Box):** 17 gezielte + Angriffstests (`tests/test_pentest_security.py`) gegen die echte + FastAPI-Anwendung inkl. SQLite-Datenbank, ausgeführt über den + ASGI-Transport (kein echtes TCP/TLS, aber derselbe Anwendungscode wie in + Produktion) sowie ergänzend manuelle Verifikation per `curl` gegen einen + laufenden `uvicorn`-Prozess. +3. **Manuelle Code-Review-Punkte:** Session-/Cookie-Handling, RBAC- + Durchsetzung, Key-Handling, Audit-Log-Integrität — im Rahmen der unter 2) + genannten Testfälle verifiziert, nicht nur gelesen. + +### 1.2 Was NICHT getestet wurde (out of scope in dieser Umgebung) + +| Bereich | Warum nicht möglich | +|---|---| +| Netzwerk-/Infrastruktur-Pentest (Portscans, TLS-Konfiguration des realen nginx/nftables-Setups, Firewall-Umgehung) | Kein reales Zielsystem/Netzwerk in dieser Sandbox verfügbar | +| RDP/`guacd`-Pfad end-to-end (echtes Windows-Ziel, echtes `guacd`+FreeRDP) | Keine RDP-Zielumgebung verfügbar; Protokoll-Handshake wurde nur gegen die Spezifikation implementiert, nicht live gegenverifiziert | +| SSH-Proxy end-to-end gegen ein echtes Linux-Ziel | Kein SSH-Zielsystem verfügbar; SSH-Verbindungslogik ist durch `asyncssh` (etabliert, nicht selbst geschrieben) abgedeckt, die serverseitige Anbindung (Key-Laden, Host-Key-Pinning) wurde isoliert/unit-artig, nicht end-to-end getestet | +| Ansible-Rollen-Ausführung gegen ein reales Debian/Ubuntu-System | Kein Root-Zielsystem verfügbar; nur `--syntax-check` + YAML/Jinja-Parsing + Variablen-/Handler-Konsistenzprüfung möglich | +| OpenSCAP/CIS-Benchmark-Tool-Lauf | Kein Zielsystem, auf dem `oscap` laufen könnte | +| Social Engineering, physische Sicherheit | Nicht anwendbar auf ein Code-Review | +| Lasttest / Denial-of-Service-Robustheit | Nicht Teil dieses Auftrags; separat zu betrachten | + +**Konsequenz:** Die hier bestätigte Sicherheit bezieht sich auf die +Anwendungslogik selbst. Vor Produktivbetrieb sind ein echter +Netzwerk-Pentest gegen die deployte Instanz und ein Live-Test des +RDP-Pfads weiterhin erforderlich (siehe Abschnitt 4). + +--- + +## 2. Ergebnisse: Statische Analyse + +### 2.1 bandit (Python-Sicherheitslinter) + +| Vorher | Nachher | +|---|---| +| 1× Medium (SQL-String-Konstruktion, B608) | 0 | +| 6× Low (breite `except`-Blöcke B110, subprocess-Nutzung B404/B603) | 0 (5× behoben durch Logging statt `pass`, 2× als geprüft/gerechtfertigt mit `# nosec` + Begründungskommentar markiert) | + +Alle Findings wurden behoben oder mit expliziter, im Code dokumentierter +Begründung als bewusste Entscheidung markiert (kein stilles Wegklicken). +Details: `app/auth/routes.py` (SQL-Konstruktion durch zwei feste +parametrisierte Statements ersetzt), `app/security/av_scan.py`, +`app/rdp_proxy/guacd_client.py`, `app/rdp_proxy/ws_tunnel.py`, +`app/ssh_proxy/terminal_ws.py`. + +### 2.2 pip-audit (Abhängigkeits-Schwachstellen) + +**16 bekannte Schwachstellen in 2 Paketen gefunden** (Stand vor Bereinigung): + +| Paket | Version (vorher) | Version (nachher) | Bekannte CVEs/Advisories | +|---|---|---|---| +| `cryptography` | 43.0.3 | **50.0.0** | 6 (u.a. PYSEC-2026-3553/3554, GHSA-537c-gmf6-5ccf) | +| `starlette` | 0.46.2 | **1.6.0** | 10 (u.a. PYSEC-2026-1941/1942/2280/2281) | +| `fastapi` | 0.115.x | **0.141.1** | (transitiv mit starlette aktualisiert) | +| `pytest`, `pytest-asyncio` (Dev-only) | 8.3.5 / 0.24.0 | **9.1.1 / 1.4.0** | PYSEC-2026-1845 | + +Nach dem Upgrade: **0 bekannte Schwachstellen** in einem vollständigen +`pip-audit`-Lauf über die gesamte aufgelöste Umgebung. `requirements.txt` +und `requirements-dev.txt` sind jetzt exakt (lockfile-artig) auf die +geprüften Versionen gepinnt. + +⚠️ **Wichtiger Fund durch die Regressionstests:** Das Upgrade von Starlette +0.46 → 1.6 hat die Aufrufkonvention von `Jinja2Templates.TemplateResponse()` +geändert (alte Signatur `TemplateResponse(name, {"request": request})` wurde +entfernt, neue Signatur ist `TemplateResponse(request, name, context)`). Ohne +die Pentest-Testsuite (`test_security_headers_present_on_every_response` u.a., +die alle Seiten inkl. `/` aufrufen) wäre dieser **funktionale Regressionsbug** +erst im Betrieb aufgefallen: `GET /`, `/dashboard`, `/terminal/{id}`, +`/rdp/{id}` hätten alle mit HTTP 500 geantwortet. Behoben in `app/main.py`; +alle vier Routen sind erneut per Test und manuellem `curl`-Aufruf verifiziert +(Status 200, korrektes HTML). + +--- + +## 3. Ergebnisse: Dynamischer Sicherheitstest (17 Testfälle) + +Alle 17 Testfälle sind **grün** (siehe `tests/test_pentest_security.py`, +ausführbar mit `pytest tests/test_pentest_security.py -v`). Zusammenfassung +nach Kategorie: + +| Kategorie | Geprüft | Ergebnis | +|---|---|---| +| **Authentifizierung** | Keine Username-Enumeration über Fehlermeldungen; Account-Lockout nach 5 Fehlversuchen (auch mit korrektem Passwort danach gesperrt); IP-basiertes Rate-Limiting greift bei >10 Versuchen/Minute; klassische SQLi-Payloads im Username-Feld führen weder zu 500 noch zu Auth-Bypass, Datenbank bleibt intakt | ✅ Bestanden | +| **Session-/Cookie-Sicherheit** | Cookie trägt `HttpOnly`, `Secure`, `SameSite=Strict`; ein mit falschem Secret gefälschtes Cookie wird abgelehnt; Passwortänderung invalidiert alte Sessions (`session_version`-Mechanismus); "Überall abmelden" invalidiert das Cookie sofort | ✅ Bestanden | +| **RBAC / IDOR** | Nicht-Admin kommt an keinen Admin-Endpunkt (weder lesend noch schreibend); Nutzer mit Rolle nur auf Hostgruppe A wird bei Host aus Hostgruppe B mit 403 blockiert — unabhängig davon, dass die Host-ID gültig/erratbar ist; eine Rolle für einen Aktionstyp (z.B. `ssh_connect`) gewährt **nicht automatisch** eine andere (z.B. `file_transfer`) auf demselben Host | ✅ Bestanden | +| **Dateitransfer-Härtung** | Uploads über dem konfigurierten Limit werden mit 413 abgelehnt; als "infected" markierte Uploads werden blockiert (400) und erzeugen **keinen** Eintrag in `file_transfers` (kein falsches Erfolgssignal) | ✅ Bestanden | +| **HTTP-Security-Header** | CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Strict-Transport-Security`, `Referrer-Policy` auf **jeder** Antwort inkl. Fehlerantworten; keine Stacktraces/Dateipfade in Fehlermeldungen | ✅ Bestanden | +| **Audit-Log-Vollständigkeit** | Fehlgeschlagene und erfolgreiche Logins sowie TOTP-Enrollment erzeugen Audit-Einträge; Hash-Chain bleibt nach einem vollständigen Testlauf (viele parallele Nutzer, Fehlversuche, Admin-Aktionen) durchgängig intakt | ✅ Bestanden | + +### 3.1 Bewertung nicht-automatisierter Beobachtungen (kein Fix nötig, dokumentiert) + +- **Pfadangabe bei SFTP-Dateitransfer** (`remote_path`-Parameter): erlaubt + beliebige Pfade wie `../../etc/passwd`. Das ist **kein Jumphost-seitiges + Path-Traversal**, da dieser Pfad ausschließlich an das **Zielsystem** per + SFTP übergeben wird (keine lokale Dateisystem-Interaktion auf dem Jumphost + selbst) — die Zugriffskontrolle liegt beim Zielsystem-Betriebssystem über + den `ssh_username`. Funktional gewollt (Nutzer soll das Zielsystem wie mit + einem SFTP-Client durchsuchen können). Empfehlung für sehr sensible + Hostgruppen: zusätzliche pfadbasierte Allow-Lists auf Anwendungsebene als + optionale Erweiterung (Konzept Kap. 12 könnte hierzu ergänzt werden). +- **`hosts.address` ist nur admin-editierbar**, nicht durch normale Nutzer + beeinflussbar — ein SSRF-artiger Angriffsvektor über beliebige Zieladressen + ist daher kein nutzerseitig ausnutzbares Risiko, sondern liegt in der + bestehenden Admin-Vertrauensgrenze (Admins könnten ohnehin beliebige + RBAC-Rechte vergeben). + +--- + +## 4. Offene Punkte vor Produktivbetrieb (Restrisiko) + +Diese Punkte waren bereits im ursprünglichen README als offen vermerkt und +bleiben es, da sie eine echte Zielumgebung erfordern, die in dieser Sandbox +nicht existiert: + +1. **Netzwerk-Pentest gegen die tatsächlich deployte Instanz** (TLS-Konfiguration + von nginx/direktem Uvicorn-TLS, Firewall-Regeln, tatsächliches + Verhalten von fail2ban) — Black-Box-Test von außen. +2. **RDP-Pfad live gegen `guacd` + FreeRDP + Windows-Ziel testen** — + insbesondere Parameter-Namen/-Reihenfolge der `connect`-Instruktion gegen + die konkret eingesetzte guacd-Version verifizieren (siehe + `app/rdp_proxy/guacd_client.py`, Docstring-Hinweis). +3. **`ansible-playbook --check` bzw. echter Rollout in einer + Staging-Umgebung** — insbesondere die `pam_faillock`-Einbindung und die + `/tmp`-/`/dev/shm`-Remount-Logik sind distributionsversionsabhängig und + sollten vor der Produktivfreigabe an einem echten System bestätigt werden + (siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`, Abschnitt + „Bekannte Einschränkungen"). +4. **OpenSCAP-Lauf** gegen das gewählte CIS/STIG-Profil zur unabhängigen + Bestätigung der Hardening-Rolle. +5. Alle bereits zuvor im README genannten funktionalen Erweiterungen + (LDAP/AD, WebAuthn, verteiltes Rate-Limiting, CSRF-Token als zusätzliche + Schicht) sind weiterhin nicht umgesetzt. + +## 5. Fazit + +Die Anwendungslogik hat den durchgeführten Sicherheitstest ohne verbleibende +offene Findings bestanden: keine Auth-/Session-Bypässe, keine RBAC-/IDOR- +Lücken, keine SQL-Injection, vollständige Audit-Protokollierung, +durchgängige Security-Header, saubere Abhängigkeiten (0 bekannte CVEs). Der +Testlauf hat zusätzlich einen realen, durch das Dependency-Upgrade +eingeführten Funktionsfehler gefunden und behoben — ein gutes Beispiel dafür, +warum Sicherheitsupdates immer mit Regressionstests kombiniert werden +sollten, nicht isoliert eingespielt werden dürfen. + +Die verbleibenden offenen Punkte sind ausschließlich solche, die eine echte +Zielinfrastruktur voraussetzen und in einer isolierten Code-Sandbox +grundsätzlich nicht abschließend geprüft werden können. diff --git a/README.md b/README.md new file mode 100644 index 0000000..836ba6d --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Jumphost Gateway — Implementierung + +Umsetzung des Konzepts `Jumphost_Konzept.md` (v0.2): browserbasiertes +SSH/RDP-Gateway mit TOTP-Pflicht, Hostgruppen-RBAC, manipulationssicherem +Audit-Log, SQLite/Python-Backend und Ansible-Deployment (mit/ohne nginx, +mehrere TLS-Modi). + +## Verzeichnisstruktur + +``` +app/ Backend (FastAPI, Python 3.11+) + security/ Crypto, Passwoerter, TOTP, Sessions, Audit-Hash-Chain, AV-Scan + auth/ Login-Flow, RBAC-Dependencies + admin/ Admin-API (User/Hosts/Hostgruppen/Rollen/SSH-Keys) + catalog/ Sicht fuer normale Nutzer (nur zugewiesene Hosts) + ssh_proxy/ SSH-Terminal-WebSocket + SFTP-Filetransfer + rdp_proxy/ Guacamole-Protokoll-Tunnel zu guacd + recordings/ Hash-verkettete Session-Aufzeichnung + db/ SQLite-Migrationen +static/, templates/ Frontend (Vanilla JS, xterm.js, guacamole-common-js) +scripts/ Betriebs-/Hilfsskripte (Admin anlegen, Assets bauen) +ansible/ Deployment (Rollen, systemd-Unit-Templates) +tests/ pytest-Suite +``` + +## Lokale Entwicklung / Ausprobieren + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements-dev.txt +bash scripts/fetch_frontend_assets.sh # vendored xterm.js / guacamole-common-js + +export JUMPHOST_ENV=development +export JUMPHOST_DATA_DIR=/tmp/jumphost-dev +export JUMPHOST_DEV_KEK=$(python3 -c "import secrets;print(secrets.token_hex(32))") +export JUMPHOST_DEV_SESSION_SECRET=$(python3 -c "import secrets;print(secrets.token_hex(32))") + +uvicorn app.main:app --reload --port 8000 +python scripts/create_admin.py --username admin # in zweitem Terminal +``` + +Danach `http://127.0.0.1:8000/` oeffnen, anmelden, TOTP einrichten (QR-Code +scannen). Fuer echte SSH-/RDP-Sessions muessen zuvor ueber die Admin-API +(`/admin/host-groups`, `/admin/hosts`, `/admin/ssh-keys`, +`/admin/hosts/{id}/ssh-keys/{id}`, `/admin/hosts/{id}/rdp-credentials`, +`/admin/roles/grant`) Hostgruppen, Hosts, Schluessel/Zugangsdaten und +Rollenzuweisungen angelegt werden. Fuer RDP muss zusaetzlich ein laufender +`guacd` erreichbar sein (siehe `JUMPHOST_GUACD_HOST`/`_PORT`). + +## Tests + +```bash +pytest -q +``` + +27 Tests decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl. +Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl. +Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment -> +Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, sowie +17 dedizierte Security-/Pentest-Tests (`tests/test_pentest_security.py`) zu +Auth-Bypass, Cookie-/Session-Manipulation, RBAC/IDOR, Injection-Versuchen, +Rate-Limiting/Lockout, Filetransfer-Haertung, Security-Headern und +Audit-Vollstaendigkeit. Details, Vorgehen und Ergebnisse: siehe +`Pentest_Report.md`. + +Manuell zusaetzlich verifiziert (siehe Entwicklungs-Log dieser Session): +Server-Start, Static-/Template-Auslieferung, Security-Header, vollstaendiger +Login+TOTP-Flow per curl, Admin-CRUD (Hostgruppe/Host anlegen), Audit-Log- +Chain-Verifikation per `/admin/audit-log/verify`. + +## Security-Tests & Haertungs-Nachweis + +- **SAST**: `bandit -r app -c .bandit.yml` sowie `pip-audit` (Dependency-CVE- + Scan) laufen sauber durch (0 Findings, 0 bekannte Schwachstellen) — Details + inkl. vorher/nachher-Tabellen und der dabei gefundenen und gefixten + Starlette-Regression in `Pentest_Report.md`. +- **DAST**: `tests/test_pentest_security.py` (17 Tests, s.o.) simuliert + konkrete Angriffsmuster gegen die laufende ASGI-App. +- **OS-Haertung (CIS/STIG)**: die `ansible/roles/os_hardening`-Rolle wurde + in dieser Session um ~10 zusaetzliche Task-Dateien vertieft (Kernel-Module, + sysctl, PAM/Passwort-Policy, erweiterte auditd-Regeln, AIDE, rkhunter, + Banner, cron/at-Restriktion, Dateirechte/sudo-Logging, SSHD-Haertung). + Vollstaendiges Mapping auf CIS-Controls inkl. bewusst nicht automatisierter + Punkte (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort, + Volltextverschluesselung, physische Sicherheit) und bekannter + Einschraenkungen: `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`. +- Abhaengigkeiten sind in `requirements.txt`/`requirements-dev.txt` exakt + auf gegen `pip-audit` gepruefte Versionen gepinnt (u.a. fastapi 0.141.1, + starlette 1.6.0, cryptography 50.0.0) — bewusste Reproduzierbarkeits-/ + Haertungsmassnahme, siehe `Pentest_Report.md` Abschnitt 2. + +## Deployment + +```bash +cd ansible +cp inventory/production.ini.example inventory/production.ini # anpassen +cp inventory/group_vars/jumphost_with_nginx.yml.example inventory/group_vars/jumphost_with_nginx.yml +ansible-vault encrypt inventory/group_vars/vault.yml # vorher aus vault.yml.example befuellen +ansible-playbook -i inventory/production.ini site.yml --ask-vault-pass +``` + +`enable_nginx_proxy` und `tls_mode` (`internal_pki` / `external_reverse_proxy` +/ `acme_public`) steuern Proxy- und TLS-Verhalten, siehe Konzept Kap. 7.2/7.2a +und `ansible/inventory/group_vars/all.yml`. + +`ansible-playbook site.yml --syntax-check` laeuft sauber durch (in dieser +Session verifiziert); ein voller `--check`-Lauf gegen eine echte +Testumgebung (inkl. `apt`, `systemd`, `guacd`-Paketverfuegbarkeit auf der +Zieldistribution) steht noch aus. + +## Was bewusst noch offen ist + +Diese Implementierung ist ein funktionsfaehiges, getestetes und in dieser +Session per SAST+DAST geprueftes Grundgeruest, aber weiterhin **kein fertig +auditiertes Produktivsystem**. Der vollstaendige Befund inkl. Methodik, +Vorgehen und Restrisikobewertung steht in `Pentest_Report.md` — die dortige +Abschnitt-4-Tabelle ("Restrisiko / vor Produktivbetrieb noch zu tun") ist die +massgebliche, aktuelle Fassung dieser Liste. Kurzfassung: + +1. **Echter Netzwerk-Penetrationstest** gegen eine laufende Instanz + (Portscan, TLS-Konfiguration live, Session-Isolation unter Last, + Guacamole-Protokoll-Fuzzing) — in dieser Sandbox ohne Netzwerkzugriff auf + ein reales Zielsystem nicht durchfuehrbar. Was stattdessen gemacht wurde: + SAST (bandit, pip-audit) und ein DAST-Testlauf gegen die App im Prozess + (17 Security-Tests, siehe `Pentest_Report.md`). +2. **RDP/guacd-Integrationstest gegen echte Zielsysteme** — die + Guacamole-Protokoll-Implementierung (`app/rdp_proxy/guacd_client.py`) wurde + gegen die Protokollspezifikation implementiert und die Handshake-Logik + lokal auf Korrektheit der Kodierung geprueft, aber NICHT gegen einen + laufenden `guacd` + FreeRDP + Windows-Ziel end-to-end getestet (keine + RDP-Zielumgebung in dieser Sitzung verfuegbar). Parameter-Namen/-Reihenfolge + sollten gegen die tatsaechlich eingesetzte guacd-Version verifiziert werden. +3. **Ansible-Rollout gegen ein reales Zielsystem** (`--check`-Dry-Run und + echter Rollout in einer Staging-Umgebung) — bisher nur + `ansible-playbook site.yml --syntax-check` sowie YAML-/Jinja2-Parsing + verifiziert, siehe `ansible/roles/os_hardening/CIS_STIG_MAPPING.md`. +4. **OpenSCAP-Compliance-Scan** (`oscap xccdf eval`) gegen das zutreffende + CIS/STIG-Profil — die `os_hardening`-Rolle wurde in dieser Session um ca. + 10 Task-Dateien vertieft (siehe CIS_STIG_MAPPING.md fuer das vollstaendige + Mapping inkl. bewusst nicht automatisierter Punkte), ersetzt aber keinen + zertifizierten Benchmark-Scan. +5. **Verteiltes Rate-Limiting** — der aktuelle Login-Rate-Limiter ist + In-Memory/Single-Process (siehe `app/security/rate_limit.py`); bei + horizontaler Skalierung durch einen geteilten Store ersetzen. +6. **fail2ban-Filter** setzt strukturierte Access-Logs mit `client_ip=`-Feld + voraus, die die App aktuell nicht schreibt — vor Produktivbetrieb ein + Access-Log-Middleware ergaenzen oder auf das Audit-Log umstellen. +7. **CSRF**: SameSite=Strict-Cookies mindern das Risiko bereits deutlich; + ein expliziter CSRF-Token fuer zustandsaendernde JSON-Requests ist als + zusaetzliche Haertungsstufe vorgesehen, aber noch nicht implementiert. +8. Alle in Konzept Kap. 12 genannten Erweiterungen (LDAP/AD, WebAuthn, + PostgreSQL-Migrationspfad, Just-in-Time-Zugriff, HA) sind noch nicht + umgesetzt. +9. **Social Engineering / physische Sicherheit / Lastest (DoS)** wurden + nicht getestet — ausserhalb des Scopes eines Code-/Konfigurations-Reviews + in dieser Sandbox-Umgebung. diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml new file mode 100644 index 0000000..81ce42a --- /dev/null +++ b/ansible/inventory/group_vars/all.yml @@ -0,0 +1,35 @@ +--- +# Gemeinsame Variablen. Umgebungsspezifische Werte in +# jumphost_with_nginx.yml / jumphost_no_nginx.yml ueberschreiben +# (oder direkt hier anpassen, wenn nur eine Umgebung existiert). + +jumphost_app_user: jumphost +jumphost_app_group: jumphost +jumphost_home: /opt/jumphost +jumphost_data_dir: /var/lib/jumphost +jumphost_venv: "{{ jumphost_home }}/venv" +jumphost_repo_src: "{{ playbook_dir }}/../.." # Projekt-Root (enthaelt app/, static/, templates/) + +jumphost_listen_uds: /run/jumphost/app.sock +jumphost_app_port: 8443 # nur relevant wenn enable_nginx_proxy: false + +guacd_port: 4822 + +# --- nginx / TLS ------------------------------------------------------------ +enable_nginx_proxy: true +tls_mode: internal_pki # internal_pki | external_reverse_proxy | acme_public +# Nur fuer tls_mode: external_reverse_proxy relevant: +external_reverse_proxy_cidr: "10.10.5.0/24" +internal_hop_plaintext_accepted: false # bewusste Ausnahme, siehe Konzept 7.2a +# Nur fuer tls_mode: acme_public relevant: +acme_domain: "jumphost.example.com" +acme_email: "admin@example.com" + +# --- Firewall ---------------------------------------------------------------- +ssh_admin_access_cidr: "10.10.1.0/24" # Management-Netz fuer SSH-Zugriff AUF den Jumphost selbst +target_networks: # Netze der Zielsysteme (fuer ausgehende Regeln) + - "10.20.0.0/16" + +# --- Backup -------------------------------------------------------------------- +backup_dir: /var/backups/jumphost +backup_retention_days: 30 diff --git a/ansible/inventory/group_vars/jumphost_no_nginx.yml.example b/ansible/inventory/group_vars/jumphost_no_nginx.yml.example new file mode 100644 index 0000000..54f25b9 --- /dev/null +++ b/ansible/inventory/group_vars/jumphost_no_nginx.yml.example @@ -0,0 +1,5 @@ +--- +# Beispiel-Override fuer eine Umgebung OHNE nginx (siehe Konzept 7.2/9): +# Die Python-App terminiert TLS direkt. +enable_nginx_proxy: false +jumphost_app_port: 8443 diff --git a/ansible/inventory/group_vars/jumphost_with_nginx.yml.example b/ansible/inventory/group_vars/jumphost_with_nginx.yml.example new file mode 100644 index 0000000..0ce8df6 --- /dev/null +++ b/ansible/inventory/group_vars/jumphost_with_nginx.yml.example @@ -0,0 +1,4 @@ +--- +# Beispiel-Override fuer eine Umgebung MIT nginx (siehe Konzept 7.2/9). +enable_nginx_proxy: true +tls_mode: internal_pki diff --git a/ansible/inventory/group_vars/vault.yml.example b/ansible/inventory/group_vars/vault.yml.example new file mode 100644 index 0000000..8d1971b --- /dev/null +++ b/ansible/inventory/group_vars/vault.yml.example @@ -0,0 +1,6 @@ +--- +# Mit `ansible-vault encrypt group_vars/vault.yml` verschluesseln, NIEMALS +# im Klartext committen (siehe Konzept 6.4/7.3). +vault_jumphost_kek: "CHANGE_ME_32_BYTES_HEX_0123456789abcdef0123456789abcdef" +vault_jumphost_session_secret: "CHANGE_ME_32_BYTES_HEX_0123456789abcdef0123456789abcdef" +vault_jumphost_initial_admin_password: "CHANGE_ME_STRONG_PASSWORD" diff --git a/ansible/inventory/production.ini.example b/ansible/inventory/production.ini.example new file mode 100644 index 0000000..f22f1ad --- /dev/null +++ b/ansible/inventory/production.ini.example @@ -0,0 +1,6 @@ +; Beispiel-Inventory. Kopieren nach production.ini und anpassen. +[jumphosts] +jumphost01.example.internal ansible_user=deploy + +[jumphosts:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/roles/backup/handlers/main.yml b/ansible/roles/backup/handlers/main.yml new file mode 100644 index 0000000..61cca34 --- /dev/null +++ b/ansible/roles/backup/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/backup/tasks/main.yml b/ansible/roles/backup/tasks/main.yml new file mode 100644 index 0000000..8e2e425 --- /dev/null +++ b/ansible/roles/backup/tasks/main.yml @@ -0,0 +1,47 @@ +--- +- name: Backup-Verzeichnis anlegen + ansible.builtin.file: + path: "{{ backup_dir }}" + state: directory + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + mode: "0700" + +- name: age installieren (Backup-Verschluesselung) + ansible.builtin.apt: + name: age + state: present + update_cache: true + +- name: Backup-Skript ausrollen + ansible.builtin.template: + src: backup.sh.j2 + dest: "{{ jumphost_home }}/scripts/backup.sh" + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + mode: "0750" + +- name: systemd-Service fuer Backup ausrollen + ansible.builtin.template: + src: jumphost-backup.service.j2 + dest: /etc/systemd/system/jumphost-backup.service + owner: root + group: root + mode: "0644" + notify: reload systemd + +- name: systemd-Timer fuer Backup ausrollen + ansible.builtin.template: + src: jumphost-backup.timer.j2 + dest: /etc/systemd/system/jumphost-backup.timer + owner: root + group: root + mode: "0644" + notify: reload systemd + +- name: Backup-Timer aktivieren + ansible.builtin.systemd: + name: jumphost-backup.timer + daemon_reload: true + enabled: true + state: started diff --git a/ansible/roles/backup/templates/backup.sh.j2 b/ansible/roles/backup/templates/backup.sh.j2 new file mode 100644 index 0000000..1159eb4 --- /dev/null +++ b/ansible/roles/backup/templates/backup.sh.j2 @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Konsistentes, verschluesseltes SQLite-Backup (Konzept 6.8). +# KEK-Backup erfolgt bewusst GETRENNT (siehe Konzept 6.8) -- dieses Skript +# sichert ausschliesslich die Datenbank, kein Schluesselmaterial. +set -euo pipefail + +DB_PATH="{{ jumphost_data_dir }}/jumphost.db" +BACKUP_DIR="{{ backup_dir }}" +TS="$(date -u +%Y%m%dT%H%M%SZ)" +OUT_PLAIN="${BACKUP_DIR}/jumphost_${TS}.db" +OUT_ENC="${OUT_PLAIN}.age" + +sqlite3 "$DB_PATH" "VACUUM INTO '${OUT_PLAIN}'" +age -r "{{ backup_age_public_key | default('AGE_PUBLIC_KEY_PLACEHOLDER') }}" -o "${OUT_ENC}" "${OUT_PLAIN}" +shred -u "${OUT_PLAIN}" + +find "$BACKUP_DIR" -name 'jumphost_*.db.age' -mtime +{{ backup_retention_days }} -delete diff --git a/ansible/roles/backup/templates/jumphost-backup.service.j2 b/ansible/roles/backup/templates/jumphost-backup.service.j2 new file mode 100644 index 0000000..9cce85f --- /dev/null +++ b/ansible/roles/backup/templates/jumphost-backup.service.j2 @@ -0,0 +1,16 @@ +[Unit] +Description=Jumphost verschluesseltes DB-Backup + +[Service] +Type=oneshot +User={{ jumphost_app_user }} +Group={{ jumphost_app_group }} +ExecStart=/usr/bin/env bash {{ jumphost_home }}/scripts/backup.sh + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths={{ backup_dir }} {{ jumphost_data_dir }} +CapabilityBoundingSet= +UMask=0077 diff --git a/ansible/roles/backup/templates/jumphost-backup.timer.j2 b/ansible/roles/backup/templates/jumphost-backup.timer.j2 new file mode 100644 index 0000000..0a7c705 --- /dev/null +++ b/ansible/roles/backup/templates/jumphost-backup.timer.j2 @@ -0,0 +1,10 @@ +[Unit] +Description=Taeglicher Jumphost-Backup-Timer + +[Timer] +OnCalendar=daily +RandomizedDelaySec=1800 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ansible/roles/fail2ban/handlers/main.yml b/ansible/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..6889677 --- /dev/null +++ b/ansible/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart fail2ban + ansible.builtin.service: + name: fail2ban + state: restarted diff --git a/ansible/roles/fail2ban/tasks/main.yml b/ansible/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..509d206 --- /dev/null +++ b/ansible/roles/fail2ban/tasks/main.yml @@ -0,0 +1,38 @@ +--- +# Zweite Verteidigungslinie gegen Brute-Force zusaetzlich zum +# Anwendungs-Rate-Limiting (Konzept 6.2). Ueberwacht die Uvicorn-/nginx- +# Access-Logs auf gehaeufte 401-Antworten vom Login-Endpunkt. + +- name: fail2ban installieren + ansible.builtin.apt: + name: fail2ban + state: present + update_cache: true + +- name: Filter fuer Jumphost-Login-Fehlversuche + ansible.builtin.copy: + dest: /etc/fail2ban/filter.d/jumphost-login.conf + owner: root + group: root + mode: "0644" + content: | + [Definition] + failregex = ^.*"POST /auth/login(/totp)? HTTP/.*" 401 .*client_ip=.*$ + ^.*"POST /auth/login(/totp)? HTTP/.*" 401 .*.*$ + ignoreregex = + +- name: Jail fuer Jumphost-Login aktivieren + ansible.builtin.copy: + dest: /etc/fail2ban/jail.d/jumphost.conf + owner: root + group: root + mode: "0644" + content: | + [jumphost-login] + enabled = true + filter = jumphost-login + logpath = /var/log/jumphost/access.log + maxretry = 8 + findtime = 300 + bantime = 1800 + notify: restart fail2ban diff --git a/ansible/roles/firewall_nftables/handlers/main.yml b/ansible/roles/firewall_nftables/handlers/main.yml new file mode 100644 index 0000000..31c046f --- /dev/null +++ b/ansible/roles/firewall_nftables/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: reload nftables + ansible.builtin.service: + name: nftables + state: restarted diff --git a/ansible/roles/firewall_nftables/tasks/main.yml b/ansible/roles/firewall_nftables/tasks/main.yml new file mode 100644 index 0000000..46f5890 --- /dev/null +++ b/ansible/roles/firewall_nftables/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# Default-Deny-Firewall (Konzept 6.7). Eingehend nur admin-SSH (aus dem +# Management-Netz) und der oeffentliche App-/nginx-Port; ausgehend nur zu den +# definierten Zielsystem-Netzen sowie DNS/NTP. + +- name: nftables installieren + ansible.builtin.apt: + name: nftables + state: present + update_cache: true + +- name: nftables-Regelsatz ausrollen + ansible.builtin.template: + src: jumphost.nft.j2 + dest: /etc/nftables.conf + owner: root + group: root + mode: "0640" + notify: reload nftables + +- name: nftables aktivieren und starten + ansible.builtin.service: + name: nftables + state: started + enabled: true diff --git a/ansible/roles/firewall_nftables/templates/jumphost.nft.j2 b/ansible/roles/firewall_nftables/templates/jumphost.nft.j2 new file mode 100644 index 0000000..4a9cbc3 --- /dev/null +++ b/ansible/roles/firewall_nftables/templates/jumphost.nft.j2 @@ -0,0 +1,46 @@ +#!/usr/sbin/nft -f +flush ruleset + +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + + iif lo accept + ct state established,related accept + ct state invalid drop + + icmp type echo-request limit rate 5/second accept + ip6 nexthdr icmpv6 icmpv6 type echo-request limit rate 5/second accept + + tcp dport 22 ip saddr {{ ssh_admin_access_cidr }} accept + +{% if enable_nginx_proxy %} + tcp dport 443 accept +{% else %} + tcp dport {{ jumphost_app_port }} accept +{% endif %} + } + + chain forward { + type filter hook forward priority 0; policy drop; + } + + chain output { + type filter hook output priority 0; policy drop; + + oif lo accept + ct state established,related accept + + udp dport 53 accept + tcp dport 53 accept + udp dport 123 accept + +{% for net in target_networks %} + ip daddr {{ net }} accept +{% endfor %} + + # ACME/interne PKI (tls_certificates-Rolle) und OS-Paketquellen + tcp dport 443 accept + tcp dport 80 accept + } +} diff --git a/ansible/roles/frontend_assets/tasks/main.yml b/ansible/roles/frontend_assets/tasks/main.yml new file mode 100644 index 0000000..ee59ee5 --- /dev/null +++ b/ansible/roles/frontend_assets/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# Vendored Frontend-Assets (xterm.js, guacamole-common-js). Bewusst OHNE +# Laufzeit-CDN-Bezug (Konzept 4.1/6.6) -- der Build laeuft hier einmalig auf +# dem Zielsystem (oder alternativ auf einem Build-Host, siehe Variante unten). + +- name: Node.js/npm fuer den Asset-Build installieren + ansible.builtin.apt: + name: npm + state: present + update_cache: true + +- name: Frontend-Assets bauen (scripts/fetch_frontend_assets.sh) + ansible.builtin.command: + cmd: "bash {{ jumphost_repo_src }}/scripts/fetch_frontend_assets.sh" + delegate_to: localhost + become: false + run_once: true + register: _asset_build + changed_when: "'Fertig' in _asset_build.stdout" + +# Hinweis: In restriktiveren Umgebungen (Jumphost selbst ohne Internetzugang +# fuer npm) diesen Task durch eine Kopie von vorgefertigten, im internen +# Artefakt-Repository abgelegten vendor/-Dateien ersetzen -- die Zielstruktur +# (static/js/vendor/*.js) bleibt identisch, siehe python_runtime-Rolle, die +# static/ anschliessend synchronisiert. diff --git a/ansible/roles/guacd/handlers/main.yml b/ansible/roles/guacd/handlers/main.yml new file mode 100644 index 0000000..c0b2dd5 --- /dev/null +++ b/ansible/roles/guacd/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart guacd + ansible.builtin.service: + name: guacd + state: restarted + enabled: true diff --git a/ansible/roles/guacd/tasks/main.yml b/ansible/roles/guacd/tasks/main.yml new file mode 100644 index 0000000..5fdfb0c --- /dev/null +++ b/ansible/roles/guacd/tasks/main.yml @@ -0,0 +1,64 @@ +--- +# guacd + FreeRDP als reine Protokoll-Engine fuer RDP (Konzept 3/4.3). +# Laeuft ausschliesslich lokal gebunden, kein Netzwerkzugriff von aussen. + +- name: guacd und FreeRDP-Plugin installieren + ansible.builtin.apt: + name: + - guacd + - libguac-client-rdp0 + state: present + update_cache: true + +- name: Dedizierten guacd-User sicherstellen (Paket legt i.d.R. bereits einen an) + ansible.builtin.user: + name: guacd + system: true + shell: /usr/sbin/nologin + create_home: false + ignore_errors: true + +- name: guacd nur an localhost binden + ansible.builtin.lineinfile: + path: /etc/guacamole/guacd.conf + regexp: '^bind_host' + line: "bind_host = 127.0.0.1" + create: true + notify: restart guacd + +- name: guacd-Port setzen + ansible.builtin.lineinfile: + path: /etc/guacamole/guacd.conf + regexp: '^bind_port' + line: "bind_port = {{ guacd_port }}" + create: true + notify: restart guacd + +- name: RDP-Laufwerksumleitungs-Verzeichnisse anlegen (Filetransfer, Konzept 4.3) + ansible.builtin.file: + path: "/var/lib/jumphost/rdp-drives" + state: directory + owner: guacd + group: guacd + mode: "0750" + +- name: Override-Verzeichnis fuer guacd-Unit anlegen + ansible.builtin.file: + path: /etc/systemd/system/guacd.service.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Gehaertete systemd-Unit fuer guacd ausrollen (Override) + ansible.builtin.template: + src: guacd.service.j2 + dest: /etc/systemd/system/guacd.service.d/override.conf + owner: root + group: root + mode: "0644" + vars: + _dummy: true + notify: + - reload systemd + - restart guacd diff --git a/ansible/roles/guacd/templates/guacd.service.j2 b/ansible/roles/guacd/templates/guacd.service.j2 new file mode 100644 index 0000000..7678f3f --- /dev/null +++ b/ansible/roles/guacd/templates/guacd.service.j2 @@ -0,0 +1,20 @@ +; Hardening-Override fuer die vom Debian/Ubuntu-Paket mitgelieferte +; guacd.service (Konzept 8.2). Nur lokale Kommunikation zur App (Unix-Domain +; oder localhost) sowie ausgehend RDP zu den Zielsystemen. +[Service] +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictNamespaces=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictSUIDSGID=true +MemoryDenyWriteExecute=true +LockPersonality=true +CapabilityBoundingSet= +ReadWritePaths=/var/lib/jumphost/rdp-drives +UMask=0077 diff --git a/ansible/roles/jumphost_app/handlers/main.yml b/ansible/roles/jumphost_app/handlers/main.yml new file mode 100644 index 0000000..70aa96d --- /dev/null +++ b/ansible/roles/jumphost_app/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart jumphost-app service + ansible.builtin.service: + name: jumphost-app + state: restarted diff --git a/ansible/roles/jumphost_app/tasks/main.yml b/ansible/roles/jumphost_app/tasks/main.yml new file mode 100644 index 0000000..17157e6 --- /dev/null +++ b/ansible/roles/jumphost_app/tasks/main.yml @@ -0,0 +1,76 @@ +--- +# Deployt die eigentliche Jumphost-Anwendung als gehaertete systemd-Unit. +# KEK und Session-Secret werden ueber systemd-creds verschluesselt abgelegt +# (Konzept 6.4) -- niemals als Klartext-Env-Variable im Unit-File. + +- name: Konfigurationsverzeichnis anlegen + ansible.builtin.file: + path: /etc/jumphost + state: directory + owner: root + group: "{{ jumphost_app_group }}" + mode: "0750" + +- name: Pruefen ob systemd-creds verfuegbar ist (systemd >= 250 empfohlen) + ansible.builtin.command: systemd-creds --version + register: _creds_check + changed_when: false + failed_when: false + +- name: Warnung ausgeben, falls systemd-creds fehlt + ansible.builtin.debug: + msg: > + WARNUNG: systemd-creds nicht verfuegbar. Fallback auf Env-Variablen + (JUMPHOST_KEK/JUMPHOST_SESSION_SECRET) in einer 0600-EnvironmentFile -- + weniger sicher als LoadCredentialEncrypted=, siehe Konzept 6.4. + when: _creds_check.rc != 0 + +- name: KEK verschluesselt ablegen (systemd-creds) + ansible.builtin.shell: | + set -o pipefail + echo -n '{{ vault_jumphost_kek }}' | systemd-creds encrypt --name=jumphost_kek - /etc/jumphost/jumphost_kek.cred + args: + creates: /etc/jumphost/jumphost_kek.cred + executable: /bin/bash + when: _creds_check.rc == 0 + no_log: true + +- name: Session-Secret verschluesselt ablegen (systemd-creds) + ansible.builtin.shell: | + set -o pipefail + echo -n '{{ vault_jumphost_session_secret }}' | systemd-creds encrypt --name=jumphost_session_secret - /etc/jumphost/jumphost_session_secret.cred + args: + creates: /etc/jumphost/jumphost_session_secret.cred + executable: /bin/bash + when: _creds_check.rc == 0 + no_log: true + +- name: Fallback-EnvironmentFile (nur falls systemd-creds fehlt) + ansible.builtin.copy: + dest: /etc/jumphost/env + owner: root + group: "{{ jumphost_app_group }}" + mode: "0640" + content: | + JUMPHOST_KEK={{ vault_jumphost_kek }} + JUMPHOST_SESSION_SECRET={{ vault_jumphost_session_secret }} + when: _creds_check.rc != 0 + no_log: true + +- name: systemd-Unit fuer die Jumphost-App ausrollen + ansible.builtin.template: + src: jumphost-app.service.j2 + dest: /etc/systemd/system/jumphost-app.service + owner: root + group: root + mode: "0644" + notify: + - reload systemd + - restart jumphost-app service + +- name: Jumphost-App aktivieren und starten + ansible.builtin.systemd: + name: jumphost-app + daemon_reload: true + enabled: true + state: started diff --git a/ansible/roles/jumphost_app/templates/jumphost-app.service.j2 b/ansible/roles/jumphost_app/templates/jumphost-app.service.j2 new file mode 100644 index 0000000..364d77a --- /dev/null +++ b/ansible/roles/jumphost_app/templates/jumphost-app.service.j2 @@ -0,0 +1,58 @@ +[Unit] +Description=Jumphost Gateway Application +After=network.target guacd.service +Wants=guacd.service + +[Service] +Type=simple +User={{ jumphost_app_user }} +Group={{ jumphost_app_group }} +WorkingDirectory={{ jumphost_home }} +Environment=JUMPHOST_ENV=production +Environment=JUMPHOST_DATA_DIR={{ jumphost_data_dir }} +Environment=JUMPHOST_LISTEN_UDS={{ jumphost_listen_uds }} +Environment=JUMPHOST_GUACD_HOST=127.0.0.1 +Environment=JUMPHOST_GUACD_PORT={{ guacd_port }} +{% if not enable_nginx_proxy %} +Environment=JUMPHOST_DIRECT_TLS=1 +{% endif %} + +{% if _creds_check.rc == 0 %} +LoadCredentialEncrypted=jumphost_kek:/etc/jumphost/jumphost_kek.cred +LoadCredentialEncrypted=jumphost_session_secret:/etc/jumphost/jumphost_session_secret.cred +{% else %} +EnvironmentFile=/etc/jumphost/env +{% endif %} + +{% if enable_nginx_proxy %} +ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --uds {{ jumphost_listen_uds }} +{% else %} +ExecStart={{ jumphost_venv }}/bin/uvicorn app.main:app --host 0.0.0.0 --port {{ jumphost_app_port }} \ + --ssl-certfile /etc/jumphost/tls/server.crt --ssl-keyfile /etc/jumphost/tls/server.key +{% endif %} + +Restart=on-failure +RestartSec=5 + +# --- Hardening (Konzept 6.7/8.1) --------------------------------------------- +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictNamespaces=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictSUIDSGID=true +MemoryDenyWriteExecute=true +LockPersonality=true +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +CapabilityBoundingSet= +ReadWritePaths={{ jumphost_data_dir }} /run/jumphost /var/log/jumphost +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/nginx_proxy/handlers/main.yml b/ansible/roles/nginx_proxy/handlers/main.yml new file mode 100644 index 0000000..f974f4b --- /dev/null +++ b/ansible/roles/nginx_proxy/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: reload nginx + ansible.builtin.service: + name: nginx + state: reloaded diff --git a/ansible/roles/nginx_proxy/tasks/main.yml b/ansible/roles/nginx_proxy/tasks/main.yml new file mode 100644 index 0000000..021fff5 --- /dev/null +++ b/ansible/roles/nginx_proxy/tasks/main.yml @@ -0,0 +1,45 @@ +--- +# nginx als vorgelagerter Reverse Proxy (Konzept 7.2/7.2a/9). Nur inkludiert +# wenn enable_nginx_proxy: true. + +- name: nginx installieren + ansible.builtin.apt: + name: nginx + state: present + update_cache: true + +- name: Default-vHost entfernen + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + +- name: Proxy-Header-Snippet ausrollen (import) + ansible.builtin.import_tasks: snippets.yml + +- name: Jumphost-vHost-Konfiguration ausrollen + ansible.builtin.template: + src: jumphost.conf.j2 + dest: /etc/nginx/sites-available/jumphost.conf + owner: root + group: root + mode: "0644" + notify: reload nginx + +- name: vHost aktivieren + ansible.builtin.file: + src: /etc/nginx/sites-available/jumphost.conf + dest: /etc/nginx/sites-enabled/jumphost.conf + state: link + notify: reload nginx + +- name: Zugriff des nginx-Users auf den App-Unix-Socket sicherstellen + ansible.builtin.user: + name: www-data + groups: "{{ jumphost_app_group }}" + append: true + +- name: nginx aktivieren und starten + ansible.builtin.service: + name: nginx + state: started + enabled: true diff --git a/ansible/roles/nginx_proxy/tasks/snippets.yml b/ansible/roles/nginx_proxy/tasks/snippets.yml new file mode 100644 index 0000000..32835ec --- /dev/null +++ b/ansible/roles/nginx_proxy/tasks/snippets.yml @@ -0,0 +1,20 @@ +--- +# Wird von tasks/main.yml importiert (siehe include_tasks unten ergaenzen, +# falls Snippet-Datei separat gepflegt werden soll). Der Einfachheit halber +# hier als eigenstaendiger Task-Block gehalten. +- name: Snippet-Verzeichnis anlegen + ansible.builtin.file: + path: /etc/nginx/snippets + state: directory + owner: root + group: root + mode: "0755" + +- name: Proxy-Header-Snippet ausrollen + ansible.builtin.template: + src: jumphost_proxy_headers.conf.j2 + dest: /etc/nginx/snippets/jumphost_proxy_headers.conf + owner: root + group: root + mode: "0644" + notify: reload nginx diff --git a/ansible/roles/nginx_proxy/templates/jumphost.conf.j2 b/ansible/roles/nginx_proxy/templates/jumphost.conf.j2 new file mode 100644 index 0000000..dfafced --- /dev/null +++ b/ansible/roles/nginx_proxy/templates/jumphost.conf.j2 @@ -0,0 +1,63 @@ +# Jumphost Reverse-Proxy-Konfiguration (Konzept 6.6/7.2a/9). +# tls_mode = {{ tls_mode }} + +{% if tls_mode == 'external_reverse_proxy' %} +# Nur der vorgelagerte externe Reverse Proxy darf X-Forwarded-For/-Proto +# setzen -- sonst waere das Audit-Log-Feld client_ip faelschbar (Konzept 7.2a). +set_real_ip_from {{ external_reverse_proxy_cidr }}; +real_ip_header X-Forwarded-For; +real_ip_recursive on; +{% endif %} + +limit_req_zone $binary_remote_addr zone=jumphost_login:10m rate=10r/m; + +server { +{% if tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted %} + # Bewusste, dokumentierte Ausnahme (Konzept 7.2a): Klartext-HTTP auf der + # Strecke externer Proxy -> Jumphost, nur zulaessig in einem eigenen, + # zugriffskontrollierten Netzsegment. + listen 443; +{% else %} + listen 443 ssl; + ssl_certificate /etc/jumphost/tls/server.crt; + ssl_certificate_key /etc/jumphost/tls/server.key; + ssl_protocols TLSv1.3; + ssl_session_cache shared:jumphost_ssl:10m; +{% endif %} + + server_name {{ acme_domain | default('_') }}; + + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + + client_max_body_size 210m; # etwas ueber MAX_UPLOAD_BYTES der App (Konzept 6.6) + + location /auth/login { + limit_req zone=jumphost_login burst=5 nodelay; + proxy_pass http://unix:{{ jumphost_listen_uds }}:; + include /etc/nginx/snippets/jumphost_proxy_headers.conf; + } + + location /ws/ { + proxy_pass http://unix:{{ jumphost_listen_uds }}:; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + include /etc/nginx/snippets/jumphost_proxy_headers.conf; + } + + location / { + proxy_pass http://unix:{{ jumphost_listen_uds }}:; + include /etc/nginx/snippets/jumphost_proxy_headers.conf; + } +} + +server { + listen 80; + server_name {{ acme_domain | default('_') }}; + location /.well-known/acme-challenge/ { root /var/www/html; } + location / { return 301 https://$host$request_uri; } +} diff --git a/ansible/roles/nginx_proxy/templates/jumphost_proxy_headers.conf.j2 b/ansible/roles/nginx_proxy/templates/jumphost_proxy_headers.conf.j2 new file mode 100644 index 0000000..1e322a0 --- /dev/null +++ b/ansible/roles/nginx_proxy/templates/jumphost_proxy_headers.conf.j2 @@ -0,0 +1,11 @@ +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +{% if tls_mode == 'external_reverse_proxy' %} +# Vom externen Proxy gesetzte Header werden dank set_real_ip_from oben +# vertrauensvoll uebernommen statt hier ueberschrieben zu werden. +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; +{% else %} +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto https; +{% endif %} diff --git a/ansible/roles/os_hardening/CIS_STIG_MAPPING.md b/ansible/roles/os_hardening/CIS_STIG_MAPPING.md new file mode 100644 index 0000000..93a379d --- /dev/null +++ b/ansible/roles/os_hardening/CIS_STIG_MAPPING.md @@ -0,0 +1,71 @@ +# CIS/STIG-Mapping der `os_hardening`-Rolle + +Diese Rolle deckt eine bewusst ausgewählte, für einen dedizierten Jumphost +(Debian/Ubuntu-Familie) relevante Teilmenge der CIS Debian/Ubuntu Linux +Benchmarks sowie einzelner DISA-STIG-typischer Controls ab. Sie ist **kein** +vollständiger, zertifizierter Benchmark-Lauf — dafür fehlt ein automatisiertes +Compliance-Scanning-Tool. Empfehlung: nach dem Rollout zusätzlich mit +`oscap xccdf eval` (OpenSCAP) gegen das jeweils zutreffende Profil prüfen und +diese Tabelle bei Abweichungen als Startpunkt für die Nacharbeit nutzen. + +Referenz-Control-IDs folgen der CIS-Nummerierung *sinngemäß* (Benchmark-Version +kann je nach Debian/Ubuntu-Release leicht abweichen); wo passend ist zusätzlich +vermerkt, wenn ein Control STIG-typisch, aber nicht CIS-nummeriert ist. + +| Bereich | CIS-Control (sinngemäß) | Umsetzung | Status | +|---|---|---|---| +| Unsichere Legacy-Dienste | 2.1.x | `tasks/packages.yml` | Implementiert | +| Automatische Sicherheitsupdates | 1.9 | `tasks/packages.yml` | Implementiert | +| Deaktivierung seltener Dateisystemtreiber | 1.1.1.1–1.1.1.8 | `tasks/kernel_modules.yml` | Implementiert | +| Deaktivierung seltener Netzwerkprotokolle | 3.4.1–3.4.4 | `tasks/kernel_modules.yml` | Implementiert | +| USB-Speichermedien deaktivieren | 1.1.23 | `tasks/kernel_modules.yml` | Implementiert | +| Netzwerk-/Kernel-sysctl-Härtung | 3.x, 1.5.x | `tasks/sysctl.yml` | Implementiert | +| Core Dumps deaktivieren | 1.5.1 | `tasks/sysctl.yml` | Implementiert | +| `/tmp`, `/dev/shm` mit noexec/nosuid/nodev | 1.1.2.x | `tasks/mounts.yml` | **Best-Effort** — nur wirksam, wenn bereits eigene Mountpoints existieren (siehe unten) | +| Passwortqualität (Länge/Komplexität) | 5.4.1 | `tasks/pam_password_policy.yml` | Implementiert (lokale OS-Konten) | +| Account-Lockout (pam_faillock) | 5.3.1 | `tasks/pam_password_policy.yml` | Implementiert, siehe Einschränkung unten | +| Passwort-Historie | 5.4.2 | `tasks/pam_password_policy.yml` | Implementiert | +| Passwort-Ablauf (`login.defs`) | 5.4.1.1–5.4.1.4 | `tasks/pam_password_policy.yml` | Implementiert | +| Erweiterte auditd-Regeln (Identität, Zeit, Netzwerk, Logins, Kernelmodule) | 4.1.3–4.1.14 | `tasks/auditd.yml` | Implementiert | +| auditd-Log-Handling (keep_logs, space_left_action) | 4.1.2.3, 4.1.2.4 | `tasks/auditd.yml` | Implementiert | +| auditd unveränderlich (`-e 2`) | 4.1.1.4 (STIG) | `tasks/auditd.yml` | Implementiert, **standardmäßig deaktiviert** (Opt-in `os_hardening_auditd_immutable`, da Reboot zum Ändern nötig) | +| Datei-Integritäts-Monitoring (AIDE) | 1.4.1, 1.4.2 | `tasks/aide.yml` | Implementiert | +| Rootkit-Scanner (rkhunter, ergänzend) | STIG-typisch, nicht CIS-nummeriert | `tasks/rootkit_scan.yml` | Implementiert | +| Anmelde-Banner | STIG-typisch (Legal Notice) | `tasks/banners.yml`, `tasks/sshd.yml` | Implementiert | +| cron/at auf autorisierte Nutzer beschränken | 2.4.1.x | `tasks/cron_at.yml` | Implementiert | +| Berechtigungen kritischer Dateien (`/etc/shadow` etc.) | 6.1.x | `tasks/file_permissions.yml` | Implementiert | +| sudo-Logging (`log_input`, `log_output`, `use_pty`) | STIG-typisch | `tasks/file_permissions.yml` | Implementiert | +| `su` auf Gruppe `sudo` beschränken | 5.6 | `tasks/file_permissions.yml` | Implementiert | +| SSH-Daemon-Härtung (Ciphers/KEX/MACs, kein Root-Login, etc.) | 5.2.x | `tasks/sshd.yml` | Implementiert | +| IPv6 deaktivieren | 3.1.1 | `tasks/sysctl.yml` | Implementiert, **Opt-in** (`os_hardening_disable_ipv6`, Default aus) | + +## Bewusst nicht automatisiert (mit Begründung) + +| Bereich | CIS-Control (sinngemäß) | Warum nicht automatisiert | +|---|---|---| +| Getrenntes Partitionslayout (`/tmp`, `/var`, `/var/log`, `/var/log/audit`, `/home` als eigene Partitionen) | 1.1.1–1.1.1.30 | Erfordert eine Neupartitionierung der Festplatte — nur zum Zeitpunkt der OS-Installation sinnvoll setzbar, nicht nachträglich per Ansible auf ein laufendes System ohne Datenverlustrisiko. **Empfehlung:** beim Erstellen des Basis-Images/der VM-Vorlage bereits mit diesem Layout provisionieren. | +| Bootloader-Passwort (GRUB) | 1.4.1 (ältere CIS-Nummerierung) | Erfordert physischen/Konsolen-Zugriff zum Testen und ist bei Cloud-/Hypervisor-Images oft irrelevant oder sogar kontraproduktiv (verhindert automatisierten Neustart). Bewusst dem jeweiligen Betreiber überlassen. | +| Volltext-Festplattenverschlüsselung | Nicht CIS-nummeriert, STIG-typisch | Muss bei der OS-Installation eingerichtet werden (LUKS o.ä.), nicht nachträglich per Ansible. In Cloud-Umgebungen häufig durch Provider-seitige Verschlüsselung (z.B. verschlüsselte Volumes) abgedeckt — separat prüfen. | +| Physische Sicherheit / BIOS-UEFI-Passwort | Nicht CIS-nummeriert | Außerhalb der Reichweite von Ansible; organisatorische/physische Maßnahme. | +| Zentrales Log-Forwarding an SIEM/Syslog-Server | Nicht CIS-nummeriert, aber im Jumphost-Konzept Kap. 6.9 gefordert | Erfordert Kenntnis der Ziel-SIEM-Infrastruktur (Empfänger-Host, Protokoll, TLS-Zertifikate) — als eigener Konfigurationspunkt vorgesehen, aber nicht Teil dieser Rolle (siehe Konzept Kap. 6.9, Erweiterungspunkt). | +| Netzwerksegmentierung / Firewalling *zwischen* Zielsystemen | Außerhalb des Jumphost-Scopes | Betrifft die Netzwerkinfrastruktur rund um den Jumphost, nicht den Jumphost selbst — Bestandteil des übergeordneten Netzwerkkonzepts. | + +## Bekannte Einschränkungen + +- **`pam_faillock`-Einbindung** (`tasks/pam_password_policy.yml`) fügt einen + Block direkt in `/etc/pam.d/common-auth` ein. Auf Systemen, die + `pam-auth-update --force` als Teil eines anderen Automatisierungsschritts + laufen lassen, kann dieser Block überschrieben werden. Für produktive + Systeme mit häufigen PAM-Änderungen wird empfohlen, stattdessen ein + eigenes `pam-auth-update`-Profil unter `/usr/share/pam-configs/` zu + pflegen (sauberer, upgrade-fest) — hier aus Gründen der Nachvollziehbarkeit + als direkter Block-Insert gehalten. +- **`/tmp`/`/dev/shm`-Remount** wirkt nur, wenn diese Pfade bereits eigene + Mountpoints sind. Ist das nicht der Fall, gibt die Rolle eine Debug-Meldung + aus, ändert aber nichts automatisch am Partitionslayout (siehe Tabelle oben). +- Diese Rolle wurde in dieser Session **nicht** gegen ein reales + Debian/Ubuntu-System ausgeführt (kein Root-/Zielsystem verfügbar) — nur + `ansible-playbook --syntax-check` sowie YAML-/Jinja2-Parsing wurden + verifiziert (siehe README, Abschnitt „Was bewusst noch offen ist"). Ein + `--check`-Lauf (Dry-Run) und ein realer Rollout-Test in einer + Staging-Umgebung stehen vor dem Produktivbetrieb noch aus. diff --git a/ansible/roles/os_hardening/defaults/main.yml b/ansible/roles/os_hardening/defaults/main.yml new file mode 100644 index 0000000..76902d7 --- /dev/null +++ b/ansible/roles/os_hardening/defaults/main.yml @@ -0,0 +1,47 @@ +--- +# Schalter fuer die vertiefte CIS/STIG-nahe Haertung dieser Rolle. +# Siehe ansible/roles/os_hardening/CIS_STIG_MAPPING.md fuer die Zuordnung +# der einzelnen Tasks zu konkreten Benchmark-Controls sowie fuer alles, was +# hier bewusst NICHT automatisiert wird (mit Begruendung). + +# AIDE (Dateiintegritaets-Monitoring) initialisieren + taeglichen Check-Timer +# einrichten. Erzeugt beim ersten Lauf spuerbare I/O-Last (voller Datei-Scan). +os_hardening_aide_enabled: true + +# rkhunter als zusaetzlicher, ergaenzender Rootkit-/Anomalie-Scanner. +os_hardening_rkhunter_enabled: true + +# /tmp und /dev/shm nach Moeglichkeit mit noexec,nosuid,nodev neu einhaengen +# (nur wenn sie BEREITS eigene Mountpoints sind -- siehe Mapping-Dokument +# Punkt "Partitionslayout" fuer den nicht automatisierbaren Teil). +os_hardening_restrict_tmp_mounts: true + +# Der Anmelde-Banner-Text (STIG-typischer Warnhinweis). An lokale +# Rechtslage/Policy anpassen. +os_hardening_login_banner: | + ****************************************************************** + * Autorisierter Zugriff ausschliesslich fuer befugte Personen. * + * Alle Aktivitaeten auf diesem System werden protokolliert und * + * koennen im Rahmen von Sicherheitsuntersuchungen ausgewertet * + * werden. Mit der Anmeldung stimmen Sie dieser Ueberwachung zu. * + ****************************************************************** + +# IPv6 komplett deaktivieren. Standardmaessig AUS (false), da dies in vielen +# Umgebungen ungewollte Nebenwirkungen hat (z.B. wenn Zielsysteme oder das +# Management-Netz IPv6 nutzen) -- bewusst ein Opt-in, kein stiller Default. +os_hardening_disable_ipv6: false + +# auditd-Konfiguration als UNVERAENDERLICH markieren (-e 2 am Ende der +# Regeln). Das ist die STIG-konforme Einstellung, erfordert danach aber +# einen Reboot, um die Audit-Regeln ueberhaupt noch aendern zu koennen -- +# daher standardmaessig AUS, damit iterative Playbook-Laeufe waehrend des +# Aufbaus nicht versehentlich aussperren. Vor der finalen Abnahme auf true setzen. +os_hardening_auditd_immutable: false + +# Passwortrichtlinie fuer LOKALE OS-Konten auf dem Jumphost selbst (NICHT zu +# verwechseln mit der Argon2id/TOTP-Pflicht der Jumphost-WEBANWENDUNG, die +# unabhaengig davon in app/security/passwords.py + totp.py durchgesetzt wird). +os_hardening_password_min_length: 14 +os_hardening_password_remember: 5 +os_hardening_faillock_deny: 5 +os_hardening_faillock_unlock_time: 900 diff --git a/ansible/roles/os_hardening/handlers/main.yml b/ansible/roles/os_hardening/handlers/main.yml new file mode 100644 index 0000000..a1faeea --- /dev/null +++ b/ansible/roles/os_hardening/handlers/main.yml @@ -0,0 +1,32 @@ +--- +- name: restart auditd + ansible.builtin.service: + name: auditd + state: restarted + +- name: restart sshd + ansible.builtin.service: + name: ssh + state: restarted + +- name: apply sysctl + ansible.builtin.command: sysctl --system + changed_when: true + +- name: update initramfs + ansible.builtin.command: update-initramfs -u + changed_when: true + +- name: remount tmp + ansible.builtin.command: mount -o remount /tmp + changed_when: true + failed_when: false # best effort, siehe Kommentar in tasks/mounts.yml + +- name: remount shm + ansible.builtin.command: mount -o remount /dev/shm + changed_when: true + failed_when: false + +- name: reload systemd (os_hardening) + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/os_hardening/tasks/aide.yml b/ansible/roles/os_hardening/tasks/aide.yml new file mode 100644 index 0000000..838298b --- /dev/null +++ b/ansible/roles/os_hardening/tasks/aide.yml @@ -0,0 +1,86 @@ +--- +# CIS 1.4.x: AIDE (Advanced Intrusion Detection Environment) als +# Datei-Integritaets-Monitoring. Ergaenzt das Anwendungs-Audit-Log +# (manipulationssichere Hash-Chain, Konzept 6.1) um eine unabhaengige, +# dateisystemweite Kontrolle -- erkennt z.B. Aenderungen an Systembinaries +# oder eingeschleuste Dateien ausserhalb des Anwendungscodes. + +- name: AIDE installieren + ansible.builtin.apt: + name: aide + state: present + update_cache: true + when: os_hardening_aide_enabled + +- name: Pruefen, ob bereits eine AIDE-Datenbank existiert + ansible.builtin.stat: + path: /var/lib/aide/aide.db + register: _aide_db + when: os_hardening_aide_enabled + +- name: Initiale AIDE-Datenbank aufbauen (kann einige Minuten dauern) + ansible.builtin.command: aideinit -y -f + when: os_hardening_aide_enabled and not _aide_db.stat.exists + async: 1800 + poll: 30 + +- name: AIDE-Wrapper-Skript fuer den taeglichen Check ausrollen + ansible.builtin.copy: + dest: /usr/local/sbin/jumphost-aide-check.sh + owner: root + group: root + mode: "0750" + content: | + #!/usr/bin/env bash + set -euo pipefail + LOGFILE=/var/log/jumphost/aide-check.log + /usr/bin/aide.wrapper --check >> "$LOGFILE" 2>&1 || { + logger -p authpriv.warning "AIDE hat Dateisystem-Abweichungen gemeldet, siehe $LOGFILE" + } + when: os_hardening_aide_enabled + +- name: systemd-Service fuer taeglichen AIDE-Check + ansible.builtin.copy: + dest: /etc/systemd/system/jumphost-aide-check.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Taeglicher AIDE-Dateiintegritaets-Check + + [Service] + Type=oneshot + ExecStart=/usr/local/sbin/jumphost-aide-check.sh + Nice=10 + IOSchedulingClass=idle + when: os_hardening_aide_enabled + notify: reload systemd (os_hardening) + +- name: systemd-Timer fuer taeglichen AIDE-Check + ansible.builtin.copy: + dest: /etc/systemd/system/jumphost-aide-check.timer + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Taeglicher AIDE-Dateiintegritaets-Check (Timer) + + [Timer] + OnCalendar=daily + RandomizedDelaySec=3600 + Persistent=true + + [Install] + WantedBy=timers.target + when: os_hardening_aide_enabled + notify: reload systemd (os_hardening) + +- name: AIDE-Check-Timer aktivieren + ansible.builtin.systemd: + name: jumphost-aide-check.timer + daemon_reload: true + enabled: true + state: started + when: os_hardening_aide_enabled diff --git a/ansible/roles/os_hardening/tasks/auditd.yml b/ansible/roles/os_hardening/tasks/auditd.yml new file mode 100644 index 0000000..45aa87e --- /dev/null +++ b/ansible/roles/os_hardening/tasks/auditd.yml @@ -0,0 +1,98 @@ +--- +# CIS 4.1.x / DISA-STIG-aehnliche Audit-Regeln. Ueber die bereits vorhandene +# Ueberwachung des Jumphost-Datenverzeichnisses hinaus wird hier ein +# Standard-Ruleset fuer sicherheitsrelevante OS-Ereignisse ergaenzt: +# Identitaets-/Rechteaenderungen, privilegierte Kommandos, Zeit-/ +# Netzwerkkonfigurationsaenderungen, Login-Ereignisse, Modulladen. + +- name: auditd + audispd-plugins installieren + ansible.builtin.apt: + name: + - auditd + - audispd-plugins + state: present + update_cache: true + +- name: Bestehende auditd-Regeln fuer das Jumphost-Datenverzeichnis + ansible.builtin.copy: + dest: /etc/audit/rules.d/10-jumphost-app.rules + owner: root + group: root + mode: "0640" + content: | + -w {{ jumphost_data_dir }}/jumphost.db -p wa -k jumphost_db + -w {{ jumphost_home }} -p wa -k jumphost_app_files + -w /etc/jumphost -p wa -k jumphost_config + notify: restart auditd + +- name: Erweiterte CIS/STIG-Audit-Regeln fuer das Basissystem + ansible.builtin.copy: + dest: /etc/audit/rules.d/20-cis-baseline.rules + owner: root + group: root + mode: "0640" + content: | + # Identitaets-/Rechteaenderungen (CIS 4.1.4) + -w /etc/passwd -p wa -k identity + -w /etc/group -p wa -k identity + -w /etc/shadow -p wa -k identity + -w /etc/gshadow -p wa -k identity + -w /etc/sudoers -p wa -k identity + -w /etc/sudoers.d/ -p wa -k identity + + # Sudo-Nutzung protokollieren (ergaenzt Defaults logfile in + # sudo_logging.yml um eine auditd-seitige, manipulationsresistentere Spur) + -a always,exit -F arch=b64 -S execve -F euid=0 -F auid!=0 -F auid!=-1 -k privileged_sudo + + # Zeitaenderungen (CIS 4.1.3) + -a always,exit -F arch=b64 -S adjtimex,settimeofday,clock_settime -k time_change + -w /etc/localtime -p wa -k time_change + + # Netzwerkkonfiguration (CIS 4.1.7) + -w /etc/hosts -p wa -k network_config + -w /etc/network/ -p wa -k network_config + -w /etc/nftables.conf -p wa -k network_config + + # Login/Logout-Ereignisse (CIS 4.1.5) + -w /var/log/faillog -p wa -k logins + -w /var/log/lastlog -p wa -k logins + -w /var/run/utmp -p wa -k session + -w /var/log/wtmp -p wa -k session + -w /var/log/btmp -p wa -k session + + # Kernel-Modul-Laden/-Entladen (CIS 4.1.13) + -a always,exit -F arch=b64 -S init_module,delete_module -k kernel_modules + + # SSH-Konfigurationsaenderungen des Jumphosts selbst + -w /etc/ssh/sshd_config -p wa -k sshd_config + -w /etc/ssh/sshd_config.d/ -p wa -k sshd_config + + # Loeschungen durch Nutzer (CIS 4.1.14, exemplarisch fuer den eigenen UID-Bereich) + -a always,exit -F arch=b64 -S unlink,unlinkat,rename,renameat -F auid>=1000 -F auid!=-1 -k file_deletion + notify: restart auditd + +- name: auditd-Regeln als unveraenderlich markieren (STIG, optional) + ansible.builtin.copy: + dest: /etc/audit/rules.d/99-immutable.rules + owner: root + group: root + mode: "0640" + content: | + # ACHTUNG: nach Aktivierung sind Aenderungen an den Audit-Regeln erst + # nach einem Reboot wieder moeglich (auditctl -e 2 sperrt bis Neustart). + -e 2 + when: os_hardening_auditd_immutable + notify: restart auditd + +- name: auditd-Log-Rotation auf "keep_logs" setzen statt Ueberschreiben (CIS 4.1.2.3) + ansible.builtin.lineinfile: + path: /etc/audit/auditd.conf + regexp: '^max_log_file_action\s*=' + line: "max_log_file_action = keep_logs" + notify: restart auditd + +- name: auditd bei vollem Log-Speicher anhalten statt Ereignisse zu verwerfen (CIS 4.1.2.4) + ansible.builtin.lineinfile: + path: /etc/audit/auditd.conf + regexp: '^space_left_action\s*=' + line: "space_left_action = email" diff --git a/ansible/roles/os_hardening/tasks/banners.yml b/ansible/roles/os_hardening/tasks/banners.yml new file mode 100644 index 0000000..ad28d4b --- /dev/null +++ b/ansible/roles/os_hardening/tasks/banners.yml @@ -0,0 +1,22 @@ +--- +# STIG-typische Anmelde-Warnhinweise (rechtlich in vielen Organisationen +# vorgeschrieben, bevor Zugriff auf ein System gewaehrt wird). + +- name: Anmelde-Banner setzen (/etc/issue, /etc/issue.net, /etc/motd) + ansible.builtin.copy: + dest: "{{ item }}" + owner: root + group: root + mode: "0644" + content: "{{ os_hardening_login_banner }}" + loop: + - /etc/issue + - /etc/issue.net + - /etc/motd + # Kein Serviceneustart noetig: /etc/issue* und /etc/motd werden bei + # jedem neuen Login/jeder neuen Verbindung frisch gelesen. + +# Die "Banner /etc/issue.net"-Direktive fuer sshd wird zentral in sshd.yml +# gesetzt (dieselbe Datei /etc/ssh/sshd_config.d/99-jumphost-hardening.conf +# wird dort komplett -- inkl. Banner-Zeile -- verwaltet, um zwei Tasks mit +# widerspruechlichem "wer besitzt diese Datei" zu vermeiden). diff --git a/ansible/roles/os_hardening/tasks/cron_at.yml b/ansible/roles/os_hardening/tasks/cron_at.yml new file mode 100644 index 0000000..9351974 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/cron_at.yml @@ -0,0 +1,36 @@ +--- +# CIS 2.4.1.x: cron/at auf autorisierte Nutzer beschraenken. + +- name: cron.deny/at.deny entfernen (deny-Listen sind fehleranfaelliger als allow-Listen) + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /etc/cron.deny + - /etc/at.deny + +- name: cron.allow / at.allow auf root und den Jumphost-Service-User beschraenken + ansible.builtin.copy: + dest: "{{ item }}" + owner: root + group: root + mode: "0600" + content: | + root + loop: + - /etc/cron.allow + - /etc/at.allow + +- name: Berechtigungen der cron-Verzeichnisse absichern (CIS 2.4.1.7-2.4.1.11) + ansible.builtin.file: + path: "{{ item }}" + owner: root + group: root + mode: "0700" + loop: + - /etc/cron.d + - /etc/cron.daily + - /etc/cron.hourly + - /etc/cron.monthly + - /etc/cron.weekly + ignore_errors: true # nicht jedes Basis-Image legt alle Verzeichnisse an diff --git a/ansible/roles/os_hardening/tasks/file_permissions.yml b/ansible/roles/os_hardening/tasks/file_permissions.yml new file mode 100644 index 0000000..dcce3ca --- /dev/null +++ b/ansible/roles/os_hardening/tasks/file_permissions.yml @@ -0,0 +1,38 @@ +--- +# CIS 6.1.x: Berechtigungen sicherheitskritischer Systemdateien; zusaetzlich +# manipulationssicheres sudo-Logging (ergaenzt die auditd-Regel +# "privileged_sudo" aus auditd.yml um ein menschenlesbares Log). + +- name: Berechtigungen sicherheitskritischer Dateien absichern + ansible.builtin.file: + path: "{{ item.path }}" + owner: root + group: "{{ item.group }}" + mode: "{{ item.mode }}" + loop: + - { path: /etc/passwd, group: root, mode: "0644" } + - { path: /etc/group, group: root, mode: "0644" } + - { path: /etc/shadow, group: shadow, mode: "0640" } + - { path: /etc/gshadow, group: shadow, mode: "0640" } + - { path: /etc/ssh/sshd_config, group: root, mode: "0600" } + ignore_errors: true # z.B. wenn die shadow-Gruppe distributionsabhaengig anders heisst + +- name: Eigenstaendiges sudo-Logfile aktivieren + ansible.builtin.copy: + dest: /etc/sudoers.d/99-jumphost-logging + owner: root + group: root + mode: "0440" + validate: "visudo -cf %s" + content: | + Defaults logfile="/var/log/sudo.log" + Defaults log_input, log_output + Defaults use_pty + Defaults passwd_tries=3 + +- name: su-Kommando auf die Gruppe "sudo" beschraenken (CIS 5.6) + ansible.builtin.lineinfile: + path: /etc/pam.d/su + regexp: '^#?\s*auth\s+required\s+pam_wheel\.so' + line: "auth required pam_wheel.so use_uid group=sudo" + insertafter: '^# Uncomment this' diff --git a/ansible/roles/os_hardening/tasks/kernel_modules.yml b/ansible/roles/os_hardening/tasks/kernel_modules.yml new file mode 100644 index 0000000..777d165 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/kernel_modules.yml @@ -0,0 +1,41 @@ +--- +# CIS 1.1.1.x: selten benoetigte Dateisysteme und exotische Netzwerk- +# protokolle per modprobe-Blacklist deaktivieren. Ein dedizierter Jumphost +# braucht keines davon; jedes deaktivierte Modul ist Angriffsflaeche weniger +# (u.a. relevant fuer angeschlossene Wechseldatentraeger und historische +# Kernel-CVEs in selten gepflegten Dateisystemtreibern). + +- name: Kernelmodule fuer seltene Dateisysteme/Protokolle blacklisten + ansible.builtin.copy: + dest: /etc/modprobe.d/jumphost-blacklist.conf + owner: root + group: root + mode: "0644" + content: | + # CIS 1.1.1.1 - 1.1.1.8 (sinngemaess): unbenoetigte Dateisysteme + install cramfs /bin/false + install freevxfs /bin/false + install jffs2 /bin/false + install hfs /bin/false + install hfsplus /bin/false + install udf /bin/false + install squashfs /bin/false + # CIS 3.4.x (sinngemaess): unbenoetigte/seltene Netzwerkprotokolle + install dccp /bin/false + install sctp /bin/false + install rds /bin/false + install tipc /bin/false + notify: update initramfs + +- name: USB-Speichermedien deaktivieren (CIS 1.1.23, sinngemaess) + ansible.builtin.copy: + dest: /etc/modprobe.d/jumphost-usb-storage.conf + owner: root + group: root + mode: "0644" + content: | + # Ein Jumphost sollte keine lokal angeschlossenen USB-Speichermedien + # einbinden muessen -- Dateitransfer laeuft ausschliesslich ueber die + # Anwendung (SFTP/RDP-Laufwerksumleitung), siehe Konzept 4.3. + install usb-storage /bin/false + notify: update initramfs diff --git a/ansible/roles/os_hardening/tasks/main.yml b/ansible/roles/os_hardening/tasks/main.yml new file mode 100644 index 0000000..3d107d0 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/main.yml @@ -0,0 +1,47 @@ +--- +# Betriebssystem-Haertung fuer den Jumphost-Server selbst (Konzept 6.7), +# vertieft auf CIS/STIG-nahes Niveau. Siehe CIS_STIG_MAPPING.md in diesem +# Rollenverzeichnis fuer die Zuordnung der einzelnen Tasks zu konkreten +# Benchmark-Controls sowie fuer alles, was bewusst NICHT automatisiert wird +# (mit Begruendung, z.B. Partitionslayout, Bootloader-Passwort, physische +# Sicherheit). +# +# Kein Ersatz fuer ein vollstaendiges CIS/STIG-Auditwerkzeug (z.B. OpenSCAP) +# -- jeder produktive Rollout sollte zusaetzlich mit `oscap xccdf eval` +# gegen das jeweilige Benchmark-Profil verifiziert werden (siehe README). + +- name: Pakete & automatische Updates + ansible.builtin.import_tasks: packages.yml + +- name: Kernelmodule (Dateisysteme/Protokolle) einschraenken + ansible.builtin.import_tasks: kernel_modules.yml + +- name: sysctl- und Core-Dump-Haertung + ansible.builtin.import_tasks: sysctl.yml + +- name: Mount-Optionen (/tmp, /dev/shm) + ansible.builtin.import_tasks: mounts.yml + +- name: PAM-/Passwort-Policy fuer lokale OS-Konten + ansible.builtin.import_tasks: pam_password_policy.yml + +- name: Erweiterte auditd-Regeln + ansible.builtin.import_tasks: auditd.yml + +- name: AIDE-Dateiintegritaets-Monitoring + ansible.builtin.import_tasks: aide.yml + +- name: rkhunter-Rootkit-Scanner + ansible.builtin.import_tasks: rootkit_scan.yml + +- name: Anmelde-Banner + ansible.builtin.import_tasks: banners.yml + +- name: cron/at auf autorisierte Nutzer beschraenken + ansible.builtin.import_tasks: cron_at.yml + +- name: Dateirechte & sudo-Logging + ansible.builtin.import_tasks: file_permissions.yml + +- name: SSH-Daemon des Jumphosts haerten + ansible.builtin.import_tasks: sshd.yml diff --git a/ansible/roles/os_hardening/tasks/mounts.yml b/ansible/roles/os_hardening/tasks/mounts.yml new file mode 100644 index 0000000..d1d7b08 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/mounts.yml @@ -0,0 +1,48 @@ +--- +# CIS 1.1.2.x (nodev/nosuid/noexec auf /tmp, /dev/shm). Best-Effort: nur +# wirksam, wenn diese Pfade BEREITS eigene Mountpoints sind. Ob das der Fall +# ist, haengt vom Partitionslayout des Basis-Images ab -- siehe +# CIS_STIG_MAPPING.md fuer den Hinweis, dass ein vollstaendig CIS-konformes +# Partitionslayout (separate /tmp, /var, /var/log, /var/log/audit, /home) +# eine bewusste Entscheidung bei der OS-Installation ist und nicht nachtraeglich +# per Ansible auf ein bestehendes System aufgepraegt werden kann, ohne die +# Platte neu zu partitionieren. + +- name: Pruefen, ob /tmp ein eigener Mountpoint ist + ansible.builtin.command: findmnt --noheadings --output SOURCE /tmp + register: _tmp_mount + changed_when: false + failed_when: false + +- name: /tmp mit noexec,nosuid,nodev in /etc/fstab absichern (falls eigener Mountpoint) + ansible.builtin.lineinfile: + path: /etc/fstab + regexp: '^\S+\s+/tmp\s+' + line: "{{ _tmp_mount.stdout }} /tmp tmpfs defaults,noexec,nosuid,nodev 0 0" + backup: true + when: os_hardening_restrict_tmp_mounts and _tmp_mount.rc == 0 and _tmp_mount.stdout != '' + notify: remount tmp + +- name: Pruefen, ob /dev/shm ein eigener Mountpoint ist + ansible.builtin.command: findmnt --noheadings --output SOURCE /dev/shm + register: _shm_mount + changed_when: false + failed_when: false + +- name: /dev/shm mit noexec,nosuid,nodev in /etc/fstab absichern (falls eigener Mountpoint) + ansible.builtin.lineinfile: + path: /etc/fstab + regexp: '^\S+\s+/dev/shm\s+' + line: "{{ _shm_mount.stdout }} /dev/shm tmpfs defaults,noexec,nosuid,nodev 0 0" + backup: true + when: os_hardening_restrict_tmp_mounts and _shm_mount.rc == 0 and _shm_mount.stdout != '' + notify: remount shm + +- name: Hinweis, falls /tmp kein eigener Mountpoint ist + ansible.builtin.debug: + msg: > + /tmp ist kein eigener Mountpoint auf diesem System -- noexec/nosuid/nodev + koennen so nicht erzwungen werden. Fuer volle CIS-Konformitaet muesste + /tmp bei der OS-Installation als eigene Partition/eigenes tmpfs angelegt + werden (siehe CIS_STIG_MAPPING.md). + when: os_hardening_restrict_tmp_mounts and (_tmp_mount.rc != 0 or _tmp_mount.stdout == '') diff --git a/ansible/roles/os_hardening/tasks/packages.yml b/ansible/roles/os_hardening/tasks/packages.yml new file mode 100644 index 0000000..17e5fe7 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/packages.yml @@ -0,0 +1,33 @@ +--- +# CIS 2.x (Services) / STIG-aequivalent: unsichere Legacy-Dienste/-Clients +# entfernen, automatische Sicherheitsupdates aktivieren. + +- name: Unnoetige/unsichere Pakete entfernen + ansible.builtin.apt: + name: "{{ item }}" + state: absent + purge: true + loop: + - telnet + - rsh-client + - talk + - nis # ypbind etc. -- veraltete, unverschluesselte Netzwerkdienste + - tftpd-hpa + - xinetd + ignore_errors: true + +- name: Automatische Sicherheitsupdates installieren + ansible.builtin.apt: + name: unattended-upgrades + state: present + update_cache: true + +- name: Unattended-upgrades aktivieren + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + owner: root + group: root + mode: "0644" + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; diff --git a/ansible/roles/os_hardening/tasks/pam_password_policy.yml b/ansible/roles/os_hardening/tasks/pam_password_policy.yml new file mode 100644 index 0000000..6c76270 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/pam_password_policy.yml @@ -0,0 +1,83 @@ +--- +# CIS 5.3.x / 5.4.x: Passwortqualitaet, Account-Lockout und Ablaufregeln fuer +# LOKALE OS-Konten auf dem Jumphost selbst (Admin-SSH-Zugang zum Jumphost- +# Server, siehe ssh_admin_access_cidr in group_vars/all.yml). +# +# WICHTIG -- Abgrenzung: Dies ist NICHT identisch mit der Argon2id/TOTP- +# Pflicht der Jumphost-WEBANWENDUNG (siehe app/security/passwords.py, +# app/security/totp.py, app/auth/routes.py). Es handelt sich um zwei +# getrennte Konten-/Auth-Systeme: die App verwaltet ihre eigenen Nutzer in +# SQLite, waehrend hier die BS-Konten der Administratoren gehaertet werden, +# die sich per SSH auf den Jumphost-Server selbst einloggen (z.B. fuer +# Wartung, Deployment, Log-Einsicht). + +- name: libpam-pwquality installieren + ansible.builtin.apt: + name: libpam-pwquality + state: present + update_cache: true + +- name: Passwortqualitaets-Policy setzen (CIS 5.4.1) + # Direkt in pwquality.conf statt eines conf.d-Snippets, da nicht jede + # Distributionsversion von libpam-pwquality ein conf.d-Verzeichnis + # unterstuetzt -- pwquality.conf selbst wird ueberall gelesen. + ansible.builtin.lineinfile: + path: /etc/security/pwquality.conf + regexp: "^#?\\s*{{ item.key }}\\s*=" + line: "{{ item.key }} = {{ item.value }}" + create: true + owner: root + group: root + mode: "0644" + loop: + - { key: "minlen", value: "{{ os_hardening_password_min_length }}" } + - { key: "dcredit", value: "-1" } + - { key: "ucredit", value: "-1" } + - { key: "ocredit", value: "-1" } + - { key: "lcredit", value: "-1" } + - { key: "retry", value: "3" } + # Kein Service-Neustart noetig: PAM liest die Datei bei jeder neuen + # Authentifizierung, kein laufender Daemon haelt sie offen. + +- name: Passwortqualitaet auch fuer root erzwingen + ansible.builtin.lineinfile: + path: /etc/security/pwquality.conf + regexp: "^#?\\s*enforce_for_root" + line: "enforce_for_root" + create: true + +- name: pam_faillock fuer Login-Lockout aktivieren (CIS 5.3.1) + ansible.builtin.blockinfile: + path: /etc/pam.d/common-auth + marker: "# {mark} ANSIBLE MANAGED BLOCK (jumphost pam_faillock)" + insertbefore: "^auth\\s+\\[success=1" + block: | + auth required pam_faillock.so preauth silent deny={{ os_hardening_faillock_deny }} unlock_time={{ os_hardening_faillock_unlock_time }} + auth [success=1 default=ignore] pam_unix.so nullok + auth [default=die] pam_faillock.so authfail deny={{ os_hardening_faillock_deny }} unlock_time={{ os_hardening_faillock_unlock_time }} + auth sufficient pam_faillock.so authsucc deny={{ os_hardening_faillock_deny }} unlock_time={{ os_hardening_faillock_unlock_time }} + # Hinweis: pam-auth-update-verwaltete Systeme (Debian/Ubuntu-Standard) + # ueberschreiben common-auth ggf. bei "pam-auth-update --force". Fuer + # produktive Systeme ist die Nutzung eines eigenen pam-auth-update-Profils + # (/usr/share/pam-configs/jumphost-faillock) die sauberere, upgrade-feste + # Alternative -- hier aus Uebersichtlichkeitsgruenden als direkter Block- + # Insert gehalten und im Mapping-Dokument als bekannte Einschraenkung vermerkt. + +- name: Passwort-Ablaufregeln in /etc/login.defs setzen (CIS 5.4.1.1-5.4.1.4) + ansible.builtin.lineinfile: + path: /etc/login.defs + regexp: "^{{ item.key }}\\s" + line: "{{ item.key }} {{ item.value }}" + loop: + - { key: "PASS_MAX_DAYS", value: "90" } + - { key: "PASS_MIN_DAYS", value: "7" } + - { key: "PASS_WARN_AGE", value: "14" } + - { key: "UMASK", value: "027" } + - { key: "ENCRYPT_METHOD", value: "SHA512" } + +- name: Passwort-Historie (pam_pwhistory) aktivieren (CIS 5.4.2) + ansible.builtin.lineinfile: + path: /etc/pam.d/common-password + regexp: '^password\s+requisite\s+pam_pwhistory\.so' + insertafter: '^password\s+requisite\s+pam_pwquality\.so' + line: "password requisite pam_pwhistory.so remember={{ os_hardening_password_remember }} use_authtok" diff --git a/ansible/roles/os_hardening/tasks/rootkit_scan.yml b/ansible/roles/os_hardening/tasks/rootkit_scan.yml new file mode 100644 index 0000000..03da591 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/rootkit_scan.yml @@ -0,0 +1,24 @@ +--- +# Ergaenzender Rootkit-/Anomalie-Scanner (rkhunter). Kein Ersatz fuer AIDE +# (dateibasierte Integritaet) oder auditd (Ereignisprotokoll), sondern eine +# dritte, unabhaengige Kontrollschicht mit eigener Signaturheuristik. + +- name: rkhunter installieren + ansible.builtin.apt: + name: rkhunter + state: present + update_cache: true + when: os_hardening_rkhunter_enabled + +- name: rkhunter-Property-Datenbank initialisieren + ansible.builtin.command: rkhunter --propupd + when: os_hardening_rkhunter_enabled + changed_when: true + +- name: Woechentlichen rkhunter-Check per systemd-Timer aktivieren (Debian-Paket-Default nutzen) + ansible.builtin.lineinfile: + path: /etc/default/rkhunter + regexp: '^CRON_DAILY_RUN=' + line: 'CRON_DAILY_RUN="true"' + create: true + when: os_hardening_rkhunter_enabled diff --git a/ansible/roles/os_hardening/tasks/sshd.yml b/ansible/roles/os_hardening/tasks/sshd.yml new file mode 100644 index 0000000..a864c84 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/sshd.yml @@ -0,0 +1,32 @@ +--- +# SSH-Daemon des Jumphosts SELBST (administrativer Zugriff auf den Server) -- +# nicht zu verwechseln mit der SSH-Proxy-Funktion der Anwendung (app/ssh_proxy), +# die eigene, unabhaengige Verbindungen zu den Zielsystemen aufbaut. +# CIS 5.2.x. + +- name: SSH-Daemon des Jumphosts haerten (vollstaendige Konfiguration inkl. Banner) + ansible.builtin.copy: + dest: /etc/ssh/sshd_config.d/99-jumphost-hardening.conf + owner: root + group: root + mode: "0644" + content: | + PermitRootLogin no + PasswordAuthentication no + KbdInteractiveAuthentication no + PermitEmptyPasswords no + X11Forwarding no + AllowTcpForwarding no + AllowAgentForwarding no + PermitTunnel no + MaxAuthTries 3 + MaxSessions 4 + LoginGraceTime 20 + ClientAliveInterval 300 + ClientAliveCountMax 2 + Banner /etc/issue.net + LogLevel VERBOSE + Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com + KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512 + MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com + notify: restart sshd diff --git a/ansible/roles/os_hardening/tasks/sysctl.yml b/ansible/roles/os_hardening/tasks/sysctl.yml new file mode 100644 index 0000000..f6143b7 --- /dev/null +++ b/ansible/roles/os_hardening/tasks/sysctl.yml @@ -0,0 +1,58 @@ +--- +# CIS 3.x (Netzwerk) / 1.5.x (Kernel-Haertung). Bewusst ueber +# ansible.builtin.copy + "sysctl --system" statt des ansible.posix.sysctl- +# Moduls (siehe Kommentar in Kap. 7/README) -- keine Zusatz-Collection als +# Voraussetzung fuer den Betrieb dieses Playbooks. + +- name: Kernel-/Netzwerk-Haertung (sysctl) + ansible.builtin.copy: + dest: /etc/sysctl.d/99-jumphost-hardening.conf + owner: root + group: root + mode: "0644" + content: | + # --- Netzwerk (CIS 3.x) --- + net.ipv4.conf.all.rp_filter = 1 + net.ipv4.conf.default.rp_filter = 1 + net.ipv4.conf.all.accept_redirects = 0 + net.ipv4.conf.default.accept_redirects = 0 + net.ipv4.conf.all.secure_redirects = 0 + net.ipv4.conf.default.secure_redirects = 0 + net.ipv4.conf.all.send_redirects = 0 + net.ipv4.conf.default.send_redirects = 0 + net.ipv4.conf.all.accept_source_route = 0 + net.ipv4.conf.default.accept_source_route = 0 + net.ipv4.conf.all.log_martians = 1 + net.ipv4.conf.default.log_martians = 1 + net.ipv4.icmp_echo_ignore_broadcasts = 1 + net.ipv4.icmp_ignore_bogus_error_responses = 1 + net.ipv4.tcp_syncookies = 1 + net.ipv4.ip_forward = 0 + net.ipv6.conf.all.accept_redirects = 0 + net.ipv6.conf.default.accept_redirects = 0 + net.ipv6.conf.all.accept_source_route = 0 + net.ipv6.conf.default.accept_source_route = 0 + {% if os_hardening_disable_ipv6 %} + net.ipv6.conf.all.disable_ipv6 = 1 + net.ipv6.conf.default.disable_ipv6 = 1 + {% endif %} + # --- Kernel-Haertung (CIS 1.5.x) --- + kernel.kptr_restrict = 2 + kernel.dmesg_restrict = 1 + kernel.randomize_va_space = 2 + kernel.yama.ptrace_scope = 1 + fs.suid_dumpable = 0 + fs.protected_hardlinks = 1 + fs.protected_symlinks = 1 + fs.protected_fifos = 2 + fs.protected_regular = 2 + notify: apply sysctl + +- name: Core Dumps zusaetzlich auf PAM-Ebene deaktivieren (CIS 1.5.1) + ansible.builtin.copy: + dest: /etc/security/limits.d/99-jumphost-no-coredumps.conf + owner: root + group: root + mode: "0644" + content: | + * hard core 0 diff --git a/ansible/roles/python_runtime/handlers/main.yml b/ansible/roles/python_runtime/handlers/main.yml new file mode 100644 index 0000000..530c9c3 --- /dev/null +++ b/ansible/roles/python_runtime/handlers/main.yml @@ -0,0 +1,8 @@ +--- +- name: restart jumphost-app + ansible.builtin.service: + name: jumphost-app + state: restarted + # Handler laeuft ggf. bevor der Service durch jumphost_app angelegt wurde; + # daher failed_when: false beim allerersten Deploy. + failed_when: false diff --git a/ansible/roles/python_runtime/tasks/main.yml b/ansible/roles/python_runtime/tasks/main.yml new file mode 100644 index 0000000..4ae7c0e --- /dev/null +++ b/ansible/roles/python_runtime/tasks/main.yml @@ -0,0 +1,93 @@ +--- +- name: Systembenutzer fuer die Jumphost-App anlegen + ansible.builtin.group: + name: "{{ jumphost_app_group }}" + system: true + +- name: Systembenutzer anlegen (kein Login-Shell, kein Home mit Zugriff fuer andere) + ansible.builtin.user: + name: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + system: true + shell: /usr/sbin/nologin + home: "{{ jumphost_home }}" + create_home: false + +- name: Python 3 + venv-Paket installieren + ansible.builtin.apt: + name: + - python3 + - python3-venv + - python3-pip + state: present + update_cache: true + +- name: Verzeichnisse anlegen + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + mode: "0750" + loop: + - "{{ jumphost_home }}" + - "{{ jumphost_data_dir }}" + - "{{ jumphost_data_dir }}/recordings" + - /run/jumphost + - /var/log/jumphost + +- name: Alten Anwendungscode entfernen (sauberes Redeploy) + ansible.builtin.file: + path: "{{ jumphost_home }}/{{ item }}" + state: absent + loop: + - app + - static + - templates + +- name: Anwendungscode kopieren + # Bewusst ueber ansible.builtin.copy statt ansible.posix.synchronize, damit + # das Playbook ohne zusaetzliche Collection auskommt und auch ohne rsync + # auf Control-Node/Zielsystem funktioniert (Konzept-Anspruch: minimale + # externe Abhaengigkeiten fuer den Deploy-Pfad selbst). + ansible.builtin.copy: + src: "{{ jumphost_repo_src }}/{{ item }}/" + dest: "{{ jumphost_home }}/{{ item }}/" + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + loop: + - app + - static + - templates + notify: restart jumphost-app + +- name: requirements.txt kopieren + ansible.builtin.copy: + src: "{{ jumphost_repo_src }}/requirements.txt" + dest: "{{ jumphost_home }}/requirements.txt" + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + notify: restart jumphost-app + +- name: Dateirechte auf Anwendungscode setzen + ansible.builtin.file: + path: "{{ jumphost_home }}" + state: directory + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + recurse: true + +- name: Virtualenv anlegen + ansible.builtin.command: + cmd: "python3 -m venv {{ jumphost_venv }}" + creates: "{{ jumphost_venv }}/bin/python" + become: true + become_user: "{{ jumphost_app_user }}" + +- name: Python-Abhaengigkeiten installieren + ansible.builtin.pip: + requirements: "{{ jumphost_home }}/requirements.txt" + virtualenv: "{{ jumphost_venv }}" + become: true + become_user: "{{ jumphost_app_user }}" + notify: restart jumphost-app diff --git a/ansible/roles/sqlite_init/tasks/main.yml b/ansible/roles/sqlite_init/tasks/main.yml new file mode 100644 index 0000000..19ac751 --- /dev/null +++ b/ansible/roles/sqlite_init/tasks/main.yml @@ -0,0 +1,25 @@ +--- +# Die Migrationen laufen bereits automatisch beim App-Start (app/db.py:init_db). +# Diese Rolle stellt lediglich sicher, dass Verzeichnis/Rechte VOR dem ersten +# Start korrekt sind und legt optional den initialen Admin-User an. + +- name: Datenverzeichnis-Rechte sicherstellen + ansible.builtin.file: + path: "{{ jumphost_data_dir }}" + state: directory + owner: "{{ jumphost_app_user }}" + group: "{{ jumphost_app_group }}" + mode: "0700" + +- name: Pruefen, ob bereits eine DB existiert + ansible.builtin.stat: + path: "{{ jumphost_data_dir }}/jumphost.db" + register: _db_stat + +- name: Hinweis fuer manuellen Erstadmin-Anlegeschritt ausgeben + ansible.builtin.debug: + msg: > + Keine bestehende Datenbank gefunden. Nach dem ersten Start des Dienstes + (Rolle jumphost_app) einmalig ausfuehren: + {{ jumphost_venv }}/bin/python {{ jumphost_home }}/scripts/create_admin.py --username admin + when: not _db_stat.stat.exists diff --git a/ansible/roles/tls_certificates/tasks/main.yml b/ansible/roles/tls_certificates/tasks/main.yml new file mode 100644 index 0000000..a51a832 --- /dev/null +++ b/ansible/roles/tls_certificates/tasks/main.yml @@ -0,0 +1,85 @@ +--- +# Drei TLS-Modi (Konzept 7.2a), gesteuert ueber tls_mode. Ergebnis ist in +# allen Faellen identisch: /etc/jumphost/tls/server.{crt,key} bzw. bei +# external_reverse_proxy zusaetzlich das interne Re-Encryption-Zertifikat +# fuer nginx unter demselben Pfad (nginx_proxy-Rolle nutzt diese Dateien). + +- name: TLS-Verzeichnis anlegen + ansible.builtin.file: + path: /etc/jumphost/tls + state: directory + owner: root + group: "{{ jumphost_app_group if not enable_nginx_proxy else 'root' }}" + mode: "0750" + +- name: "Modus internal_pki / external_reverse_proxy: Zertifikat per ACME gegen interne CA beziehen" + block: + - name: step-ca ACME-Client (step-cli) installieren + ansible.builtin.apt: + name: step-cli + state: present + ignore_errors: true # Paket ist nicht in allen Distros verfuegbar, siehe Fallback unten + + - name: Hinweis auf manuellen/alternativen Cert-Bezug + ansible.builtin.debug: + msg: > + Falls step-cli nicht verfuegbar ist: Zertifikat/Key manuell aus der + Unternehmens-PKI beziehen und ALS ansible-vault-verschluesselte + Dateien unter files/tls/{{ inventory_hostname }}.crt / .key ablegen; + diese Rolle kopiert sie dann per copy-Task (siehe unten, auskommentiert). + when: tls_mode in ['internal_pki', 'external_reverse_proxy'] + +# Alternative zu ACME: vorab per Unternehmens-PKI ausgestellte Dateien einspielen. +# - name: Vorab ausgestelltes internes Zertifikat einspielen +# ansible.builtin.copy: +# src: "files/tls/{{ inventory_hostname }}.crt" +# dest: /etc/jumphost/tls/server.crt +# when: tls_mode in ['internal_pki', 'external_reverse_proxy'] + +- name: "Modus acme_public: Certbot fuer oeffentliches Let's-Encrypt-Zertifikat" + block: + - name: certbot installieren + ansible.builtin.apt: + name: certbot + state: present + + - name: Zertifikat beziehen (nginx-Plugin, HTTP-01) + ansible.builtin.command: + cmd: > + certbot certonly --nginx --non-interactive --agree-tos + -m {{ acme_email }} -d {{ acme_domain }} + creates: "/etc/letsencrypt/live/{{ acme_domain }}/fullchain.pem" + when: enable_nginx_proxy + + - name: Symlinks fuer einheitlichen Pfad anlegen + ansible.builtin.file: + src: "/etc/letsencrypt/live/{{ acme_domain }}/{{ item.src }}" + dest: "/etc/jumphost/tls/{{ item.dest }}" + state: link + loop: + - { src: fullchain.pem, dest: server.crt } + - { src: privkey.pem, dest: server.key } + + - name: Certbot-Renew-Timer aktivieren + ansible.builtin.service: + name: certbot.timer + state: started + enabled: true + when: tls_mode == 'acme_public' + +- name: Warnung bei Klartext-internem Hop protokollieren (bewusste Ausnahme, Konzept 7.2a) + ansible.builtin.debug: + msg: > + ACHTUNG: internal_hop_plaintext_accepted=true gesetzt -- die Strecke + zwischen externem Reverse Proxy und diesem Jumphost laeuft unverschluesselt. + Dies ist NICHT die Standardempfehlung und erfordert eine dokumentierte + Risikoakzeptanz (siehe Konzept 7.2a). + when: tls_mode == 'external_reverse_proxy' and internal_hop_plaintext_accepted | default(false) + +- name: Zertifikatsdateien-Rechte absichern + ansible.builtin.file: + path: /etc/jumphost/tls + owner: root + group: "{{ jumphost_app_group if not enable_nginx_proxy else 'root' }}" + mode: "0750" + recurse: true diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..01b2420 --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,23 @@ +--- +# Jumphost-Deployment. Siehe Konzeptdokument Kap. 7 fuer Hintergrund. +# +# Steuerung ueber group_vars: +# enable_nginx_proxy: true|false (Kap. 7.2) +# tls_mode: internal_pki | external_reverse_proxy | acme_public (Kap. 7.2a) + +- hosts: jumphosts + become: true + vars_files: + - inventory/group_vars/all.yml + roles: + - os_hardening + - firewall_nftables + - fail2ban + - frontend_assets # baut static/js/vendor/* lokal VOR dem Sync in python_runtime + - python_runtime + - sqlite_init + - guacd + - jumphost_app + - tls_certificates + - { role: nginx_proxy, when: enable_nginx_proxy | default(false) } + - backup diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/__init__.py b/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin/routes.py b/app/admin/routes.py new file mode 100644 index 0000000..0e5e47b --- /dev/null +++ b/app/admin/routes.py @@ -0,0 +1,340 @@ +""" +Administrative CRUD-API: User, Hostgruppen, Hosts, Rollenvergabe, SSH-Keys. + +Alle zustandsaendernden Endpunkte sind auf globale Admins beschraenkt +(`require_global_admin`) und schreiben einen Audit-Log-Eintrag (Konzept 4.7). +Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im +Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht +vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from app.auth.deps import CurrentUser, require_global_admin +from app.db import get_db +from app.models.schemas import ( + HostCreateRequest, + HostGroupCreateRequest, + RdpCredentialsRequest, + RoleGrantRequest, + SshKeyCreateRequest, + UserCreateRequest, +) +from app.security.audit import verify_chain, write_audit_event +from app.security.crypto import encrypt_secret +from app.security.passwords import hash_password +from app.ssh_proxy.proxy import discover_and_store_host_key + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +# --- Users ----------------------------------------------------------------- + +@router.post("/users", status_code=status.HTTP_201_CREATED) +async def create_user( + payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,)) + if await cursor.fetchone() is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits") + + pw_hash = hash_password(payload.initial_password) + cursor = await conn.execute( + "INSERT INTO users (username, password_hash, is_admin, must_change_password) " + "VALUES (?, ?, ?, 1)", + (payload.username, pw_hash, int(payload.is_admin)), + ) + new_id = cursor.lastrowid + await write_audit_event( + conn, event_type="user_created", user_id=admin.id, client_ip=_client_ip(request), + details={"new_user_id": new_id, "username": payload.username, "is_admin": payload.is_admin}, + ) + await conn.commit() + return {"id": new_id, "username": payload.username} + + +@router.get("/users") +async def list_users(admin: CurrentUser = Depends(require_global_admin)): + conn = get_db() + cursor = await conn.execute( + "SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id" + ) + rows = await cursor.fetchall() + return [ + { + "id": r[0], "username": r[1], "is_admin": bool(r[2]), "is_active": bool(r[3]), + "totp_enrolled": bool(r[4]), "created_at": r[5], + } + for r in rows + ] + + +@router.post("/users/{user_id}/deactivate") +async def deactivate_user( + user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + await conn.execute( + "UPDATE users SET is_active = 0, session_version = session_version + 1 WHERE id = ?", + (user_id,), + ) + await write_audit_event( + conn, event_type="user_deactivated", user_id=admin.id, client_ip=_client_ip(request), + details={"target_user_id": user_id}, + ) + await conn.commit() + return {"status": "ok"} + + +# --- Hostgruppen ------------------------------------------------------------- + +@router.post("/host-groups", status_code=status.HTTP_201_CREATED) +async def create_host_group( + payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + cursor = await conn.execute( + "INSERT INTO host_groups (name, description) VALUES (?, ?)", + (payload.name, payload.description), + ) + new_id = cursor.lastrowid + await write_audit_event( + conn, event_type="host_group_created", user_id=admin.id, client_ip=_client_ip(request), + details={"id": new_id, "name": payload.name}, + ) + await conn.commit() + return {"id": new_id, "name": payload.name} + + +@router.get("/host-groups") +async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)): + conn = get_db() + cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id") + rows = await cursor.fetchall() + return [{"id": r[0], "name": r[1], "description": r[2]} for r in rows] + + +# --- Hosts ------------------------------------------------------------------- + +@router.post("/hosts", status_code=status.HTTP_201_CREATED) +async def create_host( + payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + cursor = await conn.execute( + """ + INSERT INTO hosts ( + host_group_id, hostname, address, protocol, port, os_type, + ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain, + rdp_require_nla, clipboard_enabled, file_transfer_enabled + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payload.host_group_id, payload.hostname, payload.address, payload.protocol, + payload.port, payload.os_type, payload.ssh_host_key_fingerprint, + payload.ssh_username, payload.rdp_username, payload.rdp_domain, + int(payload.rdp_require_nla), int(payload.clipboard_enabled), + int(payload.file_transfer_enabled), + ), + ) + new_id = cursor.lastrowid + await write_audit_event( + conn, event_type="host_created", user_id=admin.id, client_ip=_client_ip(request), + details={"id": new_id, "hostname": payload.hostname, "protocol": payload.protocol}, + ) + await conn.commit() + return {"id": new_id} + + +@router.get("/hosts") +async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depends(require_global_admin)): + conn = get_db() + if host_group_id is not None: + cursor = await conn.execute( + "SELECT id, hostname, address, protocol, port, os_type, host_group_id " + "FROM hosts WHERE host_group_id = ? ORDER BY id", + (host_group_id,), + ) + else: + cursor = await conn.execute( + "SELECT id, hostname, address, protocol, port, os_type, host_group_id FROM hosts ORDER BY id" + ) + rows = await cursor.fetchall() + return [ + { + "id": r[0], "hostname": r[1], "address": r[2], "protocol": r[3], + "port": r[4], "os_type": r[5], "host_group_id": r[6], + } + for r in rows + ] + + +@router.post("/hosts/{host_id}/discover-host-key") +async def discover_host_key( + host_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + """ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu + erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer + alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird + prominent im Audit-Log vermerkt.""" + conn = get_db() + fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id) + await write_audit_event( + conn, event_type="host_key_discovered_trust_decision", user_id=admin.id, + client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint}, + ) + await conn.commit() + return {"host_id": host_id, "fingerprint": fingerprint} + + +@router.put("/hosts/{host_id}/rdp-credentials") +async def set_rdp_credentials( + host_id: int, payload: RdpCredentialsRequest, request: Request, + admin: CurrentUser = Depends(require_global_admin), +): + """Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit + dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py).""" + conn = get_db() + encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") + await conn.execute( + "INSERT INTO rdp_credentials (host_id, password_enc, updated_at) " + "VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " + "ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, " + "updated_at = excluded.updated_at", + (host_id, encrypted), + ) + await write_audit_event( + conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id}, + ) + await conn.commit() + return {"status": "ok"} + + +# --- Rollenvergabe ------------------------------------------------------------- + +@router.post("/roles/grant") +async def grant_role( + payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) + role_row = await role_cursor.fetchone() + if role_row is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + + await conn.execute( + "INSERT OR REPLACE INTO user_hostgroup_roles " + "(user_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)", + (payload.user_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at), + ) + await write_audit_event( + conn, event_type="role_granted", user_id=admin.id, client_ip=_client_ip(request), + details={ + "target_user_id": payload.user_id, "host_group_id": payload.host_group_id, + "role": payload.role_name, "expires_at": payload.expires_at, + }, + ) + await conn.commit() + return {"status": "ok"} + + +@router.post("/roles/revoke") +async def revoke_role( + payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,)) + role_row = await role_cursor.fetchone() + if role_row is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle") + + await conn.execute( + "DELETE FROM user_hostgroup_roles WHERE user_id = ? AND host_group_id = ? AND role_id = ?", + (payload.user_id, payload.host_group_id, role_row[0]), + ) + await write_audit_event( + conn, event_type="role_revoked", user_id=admin.id, client_ip=_client_ip(request), + details={ + "target_user_id": payload.user_id, "host_group_id": payload.host_group_id, + "role": payload.role_name, + }, + ) + await conn.commit() + return {"status": "ok"} + + +# --- SSH-Keyverwaltung --------------------------------------------------------- + +@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED) +async def create_ssh_key( + payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + """Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit + dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses + Requests im Prozessspeicher (siehe Konzept 6.4: Key verlaesst den Server nie).""" + conn = get_db() + encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") + cursor = await conn.execute( + "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type) " + "VALUES (?, ?, ?, ?, ?)", + (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type), + ) + new_id = cursor.lastrowid + await write_audit_event( + conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request), + details={"id": new_id, "label": payload.label, "key_type": payload.key_type}, + ) + await conn.commit() + return {"id": new_id, "label": payload.label} + + +@router.post("/hosts/{host_id}/ssh-keys/{key_id}") +async def map_ssh_key_to_host( + host_id: int, key_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + conn = get_db() + await conn.execute( + "INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", + (host_id, key_id), + ) + await write_audit_event( + conn, event_type="ssh_key_mapped", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id, "ssh_key_id": key_id}, + ) + await conn.commit() + return {"status": "ok"} + + +# --- Audit-Log ----------------------------------------------------------------- + +@router.get("/audit-log") +async def get_audit_log( + limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_global_admin) +): + limit = max(1, min(limit, 1000)) + conn = get_db() + cursor = await conn.execute( + "SELECT id, ts, user_id, client_ip, event_type, details_json FROM audit_log " + "ORDER BY id DESC LIMIT ? OFFSET ?", + (limit, offset), + ) + rows = await cursor.fetchall() + return [ + {"id": r[0], "ts": r[1], "user_id": r[2], "client_ip": r[3], "event_type": r[4], "details": r[5]} + for r in rows + ] + + +@router.get("/audit-log/verify") +async def verify_audit_log(admin: CurrentUser = Depends(require_global_admin)): + """Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7).""" + conn = get_db() + intact, broken_at = await verify_chain(conn) + return {"intact": intact, "first_broken_id": broken_at} diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/deps.py b/app/auth/deps.py new file mode 100644 index 0000000..3bb7dd5 --- /dev/null +++ b/app/auth/deps.py @@ -0,0 +1,122 @@ +"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung.""" +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status + +from app.db import get_db +from app.rbac import user_has_role, user_has_role_for_host +from app.security.sessions import ( + SESSION_COOKIE_NAME, + decode_session_token, + is_expired, + refresh_session_token, +) + + +@dataclass +class CurrentUser: + id: int + username: str + is_admin: bool + + +async def get_current_user( + response: Response, + jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME), +) -> CurrentUser: + if jh_session is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht angemeldet") + + payload = decode_session_token(jh_session) + if payload is None or is_expired(payload): + response.delete_cookie(SESSION_COOKIE_NAME) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen oder ungueltig") + + conn = get_db() + cursor = await conn.execute( + "SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?", + (payload.user_id,), + ) + row = await cursor.fetchone() + if row is None or not row[3] or row[4] != payload.session_version: + response.delete_cookie(SESSION_COOKIE_NAME) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session ungueltig") + + # Gleitenden Idle-Timeout verlaengern (gleiche session_version/login_ts). + new_token = refresh_session_token(payload) + response.set_cookie( + SESSION_COOKIE_NAME, + new_token, + httponly=True, + secure=True, + samesite="strict", + max_age=None, # Session-Cookie; Ablauf wird serverseitig durchgesetzt + path="/", + ) + + return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2])) + + +async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None: + """Wie get_current_user(), aber fuer WebSocket-Handshakes: kein Cookie-Refresh + (WebSockets erlauben nach dem Handshake kein Set-Cookie mehr), stattdessen + wird der Idle-Timeout beim naechsten regulaeren HTTP-Request durchgesetzt.""" + token = websocket.cookies.get(SESSION_COOKIE_NAME) + if token is None: + return None + payload = decode_session_token(token) + if payload is None or is_expired(payload): + return None + + conn = get_db() + cursor = await conn.execute( + "SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?", + (payload.user_id,), + ) + row = await cursor.fetchone() + if row is None or not row[3] or row[4] != payload.session_version: + return None + return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2])) + + +async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich") + return user + + +def require_host_group_role(role_name: str): + """Dependency-Factory: prueft Rolle des Users fuer eine per Pfad-/Query-Param + uebergebene host_group_id. Globale Admins duerfen immer.""" + + async def _dep(host_group_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if user.is_admin: + return user + conn = get_db() + allowed = await user_has_role( + conn, user_id=user.id, host_group_id=host_group_id, role_name=role_name + ) + if not allowed: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Hostgruppe") + return user + + return _dep + + +def require_host_role(role_name: str): + """Dependency-Factory: prueft Rolle des Users fuer einen konkreten host_id.""" + + async def _dep(host_id: int, user: CurrentUser = Depends(get_current_user)) -> CurrentUser: + if user.is_admin: + return user + conn = get_db() + allowed = await user_has_role_for_host( + conn, user_id=user.id, host_id=host_id, role_name=role_name + ) + if not allowed: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diesen Host") + return user + + return _dep diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..5b609d3 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,330 @@ +"""Login-Flow: Passwort -> Pflicht-TOTP -> Session-Cookie (siehe Konzept 4.5/6.2).""" +from __future__ import annotations + +import base64 +import io +import secrets +from datetime import datetime, timedelta, timezone + +import qrcode +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from app.auth.deps import CurrentUser, get_current_user +from app.db import get_db +from app.models.schemas import ( + ChangePasswordRequest, + LoginRequest, + TotpConfirmRequest, + TotpLoginRequest, +) +from app.security.audit import write_audit_event +from app.security.passwords import hash_password, needs_rehash, verify_password +from app.security.pending_totp import create_pending_token, decode_pending_token +from app.security.rate_limit import login_rate_limiter +from app.security.sessions import SESSION_COOKIE_NAME, create_session_token +from app.security.totp import ( + decrypt_totp_secret, + encrypt_totp_secret, + generate_recovery_codes, + generate_totp_secret, + hash_recovery_code, + provisioning_uri, + verify_totp_code, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# Konstante Dummy-Hash-Verifikation gegen Username-Enumeration per Timing-Seitenkanal. +_DUMMY_HASH = hash_password(secrets.token_hex(16)) + + +def _client_ip(request: Request) -> str: + # Nur die direkte Peer-IP; X-Forwarded-For wird ausschliesslich vertrauenswuerdig + # ausgewertet, wenn nginx mit set_real_ip_from konfiguriert ist (Konzept 7.2a). + return request.client.host if request.client else "unknown" + + +@router.post("/login") +async def login(payload: LoginRequest, request: Request): + ip = _client_ip(request) + if not login_rate_limiter.allow(ip): + raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche, bitte warten.") + + conn = get_db() + cursor = await conn.execute( + "SELECT id, password_hash, is_active, failed_logins, locked_until, totp_enrolled " + "FROM users WHERE username = ?", + (payload.username,), + ) + row = await cursor.fetchone() + + if row is None: + verify_password(_DUMMY_HASH, payload.password) # Timing angleichen + await write_audit_event( + conn, event_type="login_failed", user_id=None, client_ip=ip, + details={"reason": "unknown_user", "username": payload.username}, + ) + await conn.commit() + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch") + + user_id, pw_hash, is_active, failed_logins, locked_until, totp_enrolled = row + + if locked_until and locked_until > datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"): + await write_audit_event( + conn, event_type="login_failed", user_id=user_id, client_ip=ip, + details={"reason": "locked"}, + ) + await conn.commit() + raise HTTPException(status.HTTP_423_LOCKED, "Konto vorruebergehend gesperrt") + + if not is_active or not verify_password(pw_hash, payload.password): + new_failed = failed_logins + 1 + # Bewusst OHNE dynamisch zusammengesetztes SQL (kein f-String mit + # Query-Fragmenten, auch wenn hier nie Nutzereingaben einfliessen) -- + # zwei feste, vollstaendig parametrisierte Statements statt eines + # "SQL-Query-Building"-Musters, das Scanner (z.B. bandit B608) und + # Reviewer sonst jedes Mal erneut pruefen muessten. + if new_failed >= 5: + delay_s = 30 * (2 ** min(new_failed - 5, 6)) # progressive Verzoegerung, gedeckelt + locked_until_ts = (datetime.now(timezone.utc) + timedelta(seconds=delay_s)).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ) + await conn.execute( + "UPDATE users SET failed_logins = ?, locked_until = ? WHERE id = ?", + (new_failed, locked_until_ts, user_id), + ) + else: + await conn.execute( + "UPDATE users SET failed_logins = ? WHERE id = ?", (new_failed, user_id) + ) + await write_audit_event( + conn, event_type="login_failed", user_id=user_id, client_ip=ip, + details={"reason": "bad_password", "failed_logins": new_failed}, + ) + await conn.commit() + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch") + + await conn.execute( + "UPDATE users SET failed_logins = 0, locked_until = NULL WHERE id = ?", (user_id,) + ) + if needs_rehash(pw_hash): + await conn.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (hash_password(payload.password), user_id), + ) + await write_audit_event( + conn, event_type="login_password_ok", user_id=user_id, client_ip=ip, details={} + ) + await conn.commit() + + pending_token = create_pending_token(user_id) + return {"pending_token": pending_token, "totp_enrolled": bool(totp_enrolled)} + + +@router.post("/totp/enroll/start") +async def totp_enroll_start(body: dict, request: Request): + """Erster Schritt der TOTP-Pflicht-Einrichtung (nur wenn noch nicht enrolled).""" + pending_token = body.get("pending_token", "") + user_id = decode_pending_token(pending_token) + if user_id is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token") + + conn = get_db() + cursor = await conn.execute( + "SELECT username, totp_enrolled FROM users WHERE id = ?", (user_id,) + ) + row = await cursor.fetchone() + if row is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltig") + username, totp_enrolled = row + if totp_enrolled: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP ist bereits eingerichtet") + + secret = generate_totp_secret() + await conn.execute( + "UPDATE users SET totp_secret_enc = ? WHERE id = ?", + (encrypt_totp_secret(secret), user_id), + ) + await write_audit_event( + conn, event_type="totp_enroll_started", user_id=user_id, + client_ip=_client_ip(request), details={}, + ) + await conn.commit() + + uri = provisioning_uri(secret, username) + qr_img = qrcode.make(uri) + buf = io.BytesIO() + qr_img.save(buf, format="PNG") + qr_b64 = base64.b64encode(buf.getvalue()).decode() + + return { + "provisioning_uri": uri, + "qr_png_base64": qr_b64, + "recovery_codes_hint": "Recovery-Codes werden erst nach erfolgreicher Bestaetigung angezeigt.", + } + + +@router.post("/totp/enroll/confirm") +async def totp_enroll_confirm(body: dict, request: Request, response: Response): + pending_token = body.get("pending_token", "") + code = str(body.get("code", "")) + user_id = decode_pending_token(pending_token) + if user_id is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token") + + conn = get_db() + cursor = await conn.execute( + "SELECT totp_secret_enc, session_version FROM users WHERE id = ?", (user_id,) + ) + row = await cursor.fetchone() + if row is None or row[0] is None: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP-Einrichtung wurde nicht gestartet") + + secret = decrypt_totp_secret(row[0]) + if not verify_totp_code(secret, code): + await write_audit_event( + conn, event_type="totp_enroll_failed", user_id=user_id, + client_ip=_client_ip(request), details={}, + ) + await conn.commit() + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Code ungueltig") + + recovery_codes = generate_recovery_codes() + await conn.execute("UPDATE users SET totp_enrolled = 1 WHERE id = ?", (user_id,)) + for rc in recovery_codes: + await conn.execute( + "INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)", + (user_id, hash_recovery_code(rc)), + ) + await write_audit_event( + conn, event_type="totp_enroll_confirmed", user_id=user_id, + client_ip=_client_ip(request), details={}, + ) + await conn.commit() + + token = create_session_token(user_id, row[1]) + response.set_cookie( + SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" + ) + return {"recovery_codes": recovery_codes} + + +@router.post("/login/totp") +async def login_totp(payload: TotpLoginRequest, request: Request, response: Response): + user_id = decode_pending_token(payload.pending_token) + if user_id is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungueltiges oder abgelaufenes Token") + + conn = get_db() + cursor = await conn.execute( + "SELECT totp_secret_enc, totp_enrolled, session_version FROM users WHERE id = ?", + (user_id,), + ) + row = await cursor.fetchone() + if row is None or not row[1]: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "TOTP nicht eingerichtet") + + secret_enc, _enrolled, session_version = row + ip = _client_ip(request) + ok = verify_totp_code(decrypt_totp_secret(secret_enc), payload.code) + + if not ok: + # Recovery-Code als Fallback pruefen. + code_hash = hash_recovery_code(payload.code) + rc_cursor = await conn.execute( + "SELECT id FROM recovery_codes WHERE user_id = ? AND code_hash = ? AND used_at IS NULL", + (user_id, code_hash), + ) + rc_row = await rc_cursor.fetchone() + if rc_row is not None: + await conn.execute( + "UPDATE recovery_codes SET used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (rc_row[0],), + ) + await write_audit_event( + conn, event_type="login_recovery_code_used", user_id=user_id, client_ip=ip, details={} + ) + ok = True + + if not ok: + await write_audit_event( + conn, event_type="login_totp_failed", user_id=user_id, client_ip=ip, details={} + ) + await conn.commit() + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "TOTP-Code ungueltig") + + await write_audit_event( + conn, event_type="login_success", user_id=user_id, client_ip=ip, details={} + ) + await conn.commit() + + token = create_session_token(user_id, session_version) + response.set_cookie( + SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" + ) + return {"status": "ok"} + + +@router.post("/logout") +async def logout(request: Request, response: Response, user: CurrentUser = Depends(get_current_user)): + conn = get_db() + await write_audit_event( + conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={} + ) + await conn.commit() + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + return {"status": "ok"} + + +@router.post("/logout-everywhere") +async def logout_everywhere( + request: Request, response: Response, user: CurrentUser = Depends(get_current_user) +): + """Invalidiert alle ausgestellten Session-Cookies dieses Users sofort.""" + conn = get_db() + await conn.execute( + "UPDATE users SET session_version = session_version + 1 WHERE id = ?", (user.id,) + ) + await write_audit_event( + conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={} + ) + await conn.commit() + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + return {"status": "ok"} + + +@router.post("/change-password") +async def change_password( + payload: ChangePasswordRequest, + request: Request, + response: Response, + user: CurrentUser = Depends(get_current_user), +): + conn = get_db() + cursor = await conn.execute("SELECT password_hash, session_version FROM users WHERE id = ?", (user.id,)) + row = await cursor.fetchone() + if row is None or not verify_password(row[0], payload.current_password): + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Aktuelles Passwort falsch") + + new_hash = hash_password(payload.new_password) + new_version = row[1] + 1 # invalidiert alle anderen laufenden Sessions dieses Users + await conn.execute( + "UPDATE users SET password_hash = ?, session_version = ?, must_change_password = 0, " + "password_changed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", + (new_hash, new_version, user.id), + ) + await write_audit_event( + conn, event_type="password_changed", user_id=user.id, client_ip=_client_ip(request), details={} + ) + await conn.commit() + + token = create_session_token(user.id, new_version) + response.set_cookie( + SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/" + ) + return {"status": "ok"} + + +@router.get("/me") +async def me(user: CurrentUser = Depends(get_current_user)): + return {"id": user.id, "username": user.username, "is_admin": user.is_admin} diff --git a/app/catalog/__init__.py b/app/catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/catalog/routes.py b/app/catalog/routes.py new file mode 100644 index 0000000..66d1d9f --- /dev/null +++ b/app/catalog/routes.py @@ -0,0 +1,67 @@ +"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine +Rolle in der jeweiligen Hostgruppe vergeben hat (Konzept 4.6).""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.auth.deps import CurrentUser, get_current_user +from app.db import get_db + +router = APIRouter(prefix="/catalog", tags=["catalog"]) + + +@router.get("/hosts") +async def my_hosts(user: CurrentUser = Depends(get_current_user)): + conn = get_db() + if user.is_admin: + cursor = await conn.execute( + "SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, " + "g.name, h.clipboard_enabled, h.file_transfer_enabled " + "FROM hosts h JOIN host_groups g ON g.id = h.host_group_id " + "WHERE h.is_active = 1 ORDER BY g.name, h.hostname" + ) + rows = await cursor.fetchall() + hosts = [dict(zip( + ("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name", + "clipboard_enabled", "file_transfer_enabled"), r + )) for r in rows] + for h in hosts: + h["can_connect"] = True + h["can_file_transfer"] = True + return hosts + + cursor = await conn.execute( + """ + SELECT DISTINCT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, + g.name, h.clipboard_enabled, h.file_transfer_enabled + FROM hosts h + JOIN host_groups g ON g.id = h.host_group_id + JOIN user_hostgroup_roles uhr ON uhr.host_group_id = h.host_group_id + JOIN roles r ON r.id = uhr.role_id + WHERE h.is_active = 1 AND uhr.user_id = ? + AND r.name IN ('ssh_connect', 'rdp_connect') + AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ORDER BY g.name, h.hostname + """, + (user.id,), + ) + rows = await cursor.fetchall() + hosts = [dict(zip( + ("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name", + "clipboard_enabled", "file_transfer_enabled"), r + )) for r in rows] + + ft_cursor = await conn.execute( + """ + SELECT DISTINCT uhr.host_group_id FROM user_hostgroup_roles uhr + JOIN roles r ON r.id = uhr.role_id + WHERE uhr.user_id = ? AND r.name = 'file_transfer' + AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) + """, + (user.id,), + ) + ft_groups = {row[0] for row in await ft_cursor.fetchall()} + for h in hosts: + h["can_connect"] = True + h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"]) + return hosts diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..46595c3 --- /dev/null +++ b/app/config.py @@ -0,0 +1,75 @@ +""" +Zentrale Konfiguration der Jumphost-Anwendung. + +Secrets (KEK, Session-Signaturschluessel) werden bevorzugt ueber systemd +Credentials geladen (siehe systemd LoadCredentialEncrypted= im Unit-File, +$CREDENTIALS_DIRECTORY zur Laufzeit). Fuer lokale Entwicklung/Tests wird auf +Umgebungsvariablen bzw. eine lokale .env-Datei zurueckgefallen -- das ist +NICHT fuer den Produktivbetrieb gedacht. +""" +from __future__ import annotations + +import os +import secrets +from dataclasses import dataclass, field +from pathlib import Path + + +def _read_credential(name: str, env_fallback: str | None = None, *, required: bool = True) -> bytes | None: + """Liest ein Secret aus $CREDENTIALS_DIRECTORY (systemd-creds) oder Fallback-Env.""" + cred_dir = os.environ.get("CREDENTIALS_DIRECTORY") + if cred_dir: + cred_path = Path(cred_dir) / name + if cred_path.exists(): + return cred_path.read_bytes().strip() + if env_fallback and env_fallback in os.environ: + return os.environ[env_fallback].encode() + if required: + raise RuntimeError( + f"Secret '{name}' weder ueber systemd-creds noch ueber Env-Variable " + f"'{env_fallback}' verfuegbar. In Produktion MUSS dies ueber " + f"systemd LoadCredentialEncrypted= bereitgestellt werden." + ) + return None + + +@dataclass +class Settings: + app_env: str = os.environ.get("JUMPHOST_ENV", "development") + data_dir: Path = Path(os.environ.get("JUMPHOST_DATA_DIR", "/var/lib/jumphost")) + db_path: Path = field(init=False) + recordings_dir: Path = field(init=False) + + # Key-Encryption-Key fuer AES-256-GCM (verschluesselt SSH-Keys/TOTP-Secrets in der DB) + kek: bytes = field(init=False) + # separater Schluessel fuer Session-Cookie-Signatur (Schluesseltrennung, siehe Konzept 6.2) + session_secret: bytes = field(init=False) + + listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock") + guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1") + guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822")) + + session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900")) + session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800")) + max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5")) + lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30")) + + def __post_init__(self) -> None: + self.db_path = self.data_dir / "jumphost.db" + self.recordings_dir = self.data_dir / "recordings" + + if self.app_env == "development": + # Nur fuer lokale Entwicklung: deterministisch aus Env oder Zufallswert je Prozessstart. + kek_hex = os.environ.get("JUMPHOST_DEV_KEK") + self.kek = bytes.fromhex(kek_hex) if kek_hex else secrets.token_bytes(32) + sess_hex = os.environ.get("JUMPHOST_DEV_SESSION_SECRET") + self.session_secret = bytes.fromhex(sess_hex) if sess_hex else secrets.token_bytes(32) + else: + self.kek = _read_credential("jumphost_kek", "JUMPHOST_KEK") + self.session_secret = _read_credential("jumphost_session_secret", "JUMPHOST_SESSION_SECRET") + + if len(self.kek) != 32: + raise RuntimeError("KEK muss genau 32 Bytes (256 Bit) lang sein.") + + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..d0c5342 --- /dev/null +++ b/app/db.py @@ -0,0 +1,69 @@ +""" +Datenbankzugriff (SQLite, WAL-Modus) + einfacher, idempotenter Migration-Runner. + +Bewusst ohne schweres ORM gehalten: alle Queries sind strikt parametrisiert +(nie String-Concat mit Nutzereingaben), siehe Security-Konzept 6.6. +""" +from __future__ import annotations + +import logging +from pathlib import Path + +import aiosqlite + +from app.config import settings + +logger = logging.getLogger("jumphost.db") + +MIGRATIONS_DIR = Path(__file__).parent / "db" / "migrations" + +_connection: aiosqlite.Connection | None = None + + +async def init_db() -> None: + """Legt das Datenverzeichnis an, oeffnet die DB und wendet Migrationen an.""" + settings.data_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + settings.recordings_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + + global _connection + _connection = await aiosqlite.connect(settings.db_path, isolation_level=None) + await _connection.execute("PRAGMA journal_mode = WAL;") + await _connection.execute("PRAGMA foreign_keys = ON;") + await _connection.execute("PRAGMA busy_timeout = 5000;") + await _apply_migrations(_connection) + + try: + settings.db_path.chmod(0o600) + except OSError: + logger.warning("Konnte Dateirechte der DB nicht setzen (%s)", settings.db_path) + + +async def _apply_migrations(conn: aiosqlite.Connection) -> None: + await conn.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations " + "(filename TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')))" + ) + applied = {row[0] async for row in await conn.execute("SELECT filename FROM schema_migrations")} + + for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")): + if migration_file.name in applied: + continue + logger.info("Wende Migration an: %s", migration_file.name) + sql = migration_file.read_text(encoding="utf-8") + await conn.executescript(sql) + await conn.execute( + "INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,) + ) + + +async def close_db() -> None: + global _connection + if _connection is not None: + await _connection.close() + _connection = None + + +def get_db() -> aiosqlite.Connection: + if _connection is None: + raise RuntimeError("Datenbank ist nicht initialisiert (init_db() aufrufen).") + return _connection diff --git a/app/db/migrations/0001_initial.sql b/app/db/migrations/0001_initial.sql new file mode 100644 index 0000000..fce7818 --- /dev/null +++ b/app/db/migrations/0001_initial.sql @@ -0,0 +1,144 @@ +-- Initiales Schema. Siehe Konzeptdokument Kap. 5. +-- Wird vom Migration-Runner (app/db.py) einmalig und idempotent angewendet. + +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + totp_secret_enc BLOB, + totp_enrolled INTEGER NOT NULL DEFAULT 0, + is_active INTEGER NOT NULL DEFAULT 1, + is_admin INTEGER NOT NULL DEFAULT 0, + failed_logins INTEGER NOT NULL DEFAULT 0, + locked_until TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + password_changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + must_change_password INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS recovery_codes ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_recovery_codes_user ON recovery_codes(user_id); + +CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +); +INSERT OR IGNORE INTO roles (id, name) VALUES + (1, 'ssh_connect'), + (2, 'rdp_connect'), + (3, 'file_transfer'), + (4, 'clipboard'), + (5, 'session_recording_view'), + (6, 'admin_hostgroup'); + +CREATE TABLE IF NOT EXISTS host_groups ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE IF NOT EXISTS hosts ( + id INTEGER PRIMARY KEY, + host_group_id INTEGER NOT NULL REFERENCES host_groups(id), + hostname TEXT NOT NULL, + address TEXT NOT NULL, + protocol TEXT NOT NULL CHECK (protocol IN ('ssh','rdp')), + port INTEGER NOT NULL, + os_type TEXT NOT NULL CHECK (os_type IN ('linux','windows')), + ssh_host_key_fingerprint TEXT, + ssh_username TEXT, + rdp_username TEXT, + rdp_domain TEXT, + rdp_require_nla INTEGER NOT NULL DEFAULT 1, + clipboard_enabled INTEGER NOT NULL DEFAULT 1, + file_transfer_enabled INTEGER NOT NULL DEFAULT 1, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_hosts_group ON hosts(host_group_id); + +CREATE TABLE IF NOT EXISTS ssh_keys ( + id INTEGER PRIMARY KEY, + label TEXT NOT NULL, + owner_user_id INTEGER REFERENCES users(id), + private_key_enc BLOB NOT NULL, + public_key TEXT NOT NULL, + key_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + rotated_at TEXT, + expires_at TEXT +); + +CREATE TABLE IF NOT EXISTS host_ssh_key_map ( + host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + ssh_key_id INTEGER NOT NULL REFERENCES ssh_keys(id), + PRIMARY KEY (host_id, ssh_key_id) +); + +CREATE TABLE IF NOT EXISTS user_hostgroup_roles ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + host_group_id INTEGER NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id), + granted_by INTEGER REFERENCES users(id), + granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + expires_at TEXT, + PRIMARY KEY (user_id, host_group_id, role_id) +); +CREATE INDEX IF NOT EXISTS idx_uhr_user ON user_hostgroup_roles(user_id); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + host_id INTEGER NOT NULL REFERENCES hosts(id), + protocol TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + ended_at TEXT, + client_ip TEXT NOT NULL, + recording_path TEXT, + end_reason TEXT +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_host ON sessions(host_id); + +CREATE TABLE IF NOT EXISTS file_transfers ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES sessions(id), + direction TEXT NOT NULL CHECK (direction IN ('upload','download')), + filename TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + av_scan_result TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_ft_session ON file_transfers(session_id); + +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + user_id INTEGER REFERENCES users(id), + client_ip TEXT, + event_type TEXT NOT NULL, + details_json TEXT NOT NULL, + prev_hash TEXT NOT NULL, + entry_hash TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts); +CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); + +-- Audit-Log ist auf DB-Ebene append-only: UPDATE/DELETE werden hart verweigert. +CREATE TRIGGER IF NOT EXISTS no_audit_update BEFORE UPDATE ON audit_log +BEGIN SELECT RAISE(ABORT, 'audit_log ist append-only'); END; +CREATE TRIGGER IF NOT EXISTS no_audit_delete BEFORE DELETE ON audit_log +BEGIN SELECT RAISE(ABORT, 'audit_log ist append-only'); END; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); diff --git a/app/db/migrations/0002_session_version.sql b/app/db/migrations/0002_session_version.sql new file mode 100644 index 0000000..3bf6c64 --- /dev/null +++ b/app/db/migrations/0002_session_version.sql @@ -0,0 +1,5 @@ +-- Ermoeglicht serverseitiges Invalidieren aller Sessions eines Users +-- (Passwortaenderung, "ueberall abmelden", Admin-Sperre) ohne eigene +-- Session-Tabelle: das Session-Cookie enthaelt session_version und wird nur +-- akzeptiert, wenn der Wert mit dem aktuellen DB-Wert uebereinstimmt. +ALTER TABLE users ADD COLUMN session_version INTEGER NOT NULL DEFAULT 1; diff --git a/app/db/migrations/0003_rdp_credentials.sql b/app/db/migrations/0003_rdp_credentials.sql new file mode 100644 index 0000000..194fd87 --- /dev/null +++ b/app/db/migrations/0003_rdp_credentials.sql @@ -0,0 +1,7 @@ +-- RDP-Zugangsdaten getrennt von SSH-Keys, ebenfalls AES-256-GCM-verschluesselt +-- (KEK, associated_data="rdp_password"). Ein Datensatz pro Host. +CREATE TABLE IF NOT EXISTS rdp_credentials ( + host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE, + password_enc BLOB NOT NULL, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e91ecd1 --- /dev/null +++ b/app/main.py @@ -0,0 +1,99 @@ +""" +FastAPI-Einstiegspunkt. Bindet Security-Header, Router und Static/Template- +Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening). +""" +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from app.admin.routes import router as admin_router +from app.auth.deps import get_current_user +from app.auth.routes import router as auth_router +from app.catalog.routes import router as catalog_router +from app.db import close_db, init_db +from app.rdp_proxy.ws_tunnel import router as rdp_ws_router +from app.ssh_proxy.sftp import router as sftp_router +from app.ssh_proxy.terminal_ws import router as ssh_ws_router + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + await close_db() + + +app = FastAPI(title="Jumphost Gateway", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) + +app.include_router(auth_router) +app.include_router(admin_router) +app.include_router(catalog_router) +app.include_router(ssh_ws_router) +app.include_router(sftp_router) +app.include_router(rdp_ws_router) + +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + + +@app.middleware("http") +async def security_headers_middleware(request: Request, call_next): + """Setzt die in Konzept 6.6 geforderten Security-Header auf jede Antwort. + Laeuft unabhaengig davon, ob nginx vorgeschaltet ist (Defense-in-Depth -- + nginx setzt in der Praxis dieselben Header zusaetzlich, siehe ansible/roles/nginx_proxy).""" + response = await call_next(request) + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "clipboard-read=(self), clipboard-write=(self), fullscreen=(self)" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "img-src 'self' data:; " + "connect-src 'self' ws: wss:; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ) + response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains" + return response + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + # Hinweis: seit Starlette >=1.x ist "TemplateResponse(request, name, ...)" + # die aktuelle Aufrufkonvention (die alte "TemplateResponse(name, {...})" + # wurde entfernt) -- beim Dependency-Upgrade im Rahmen der pip-audit- + # Bereinigung angepasst und per Pentest-Testsuite regressionsgetestet. + return templates.TemplateResponse(request, "login.html", {}) + + +@app.get("/dashboard", response_class=HTMLResponse) +async def dashboard(request: Request): + # Auth-Pruefung erfolgt clientseitig ueber GET /auth/me (401 -> Redirect zu /); + # serverseitig zusaetzlich abgesichert, sobald Templates dynamische Inhalte rendern. + return templates.TemplateResponse(request, "dashboard.html", {}) + + +@app.get("/terminal/{host_id}", response_class=HTMLResponse) +async def terminal_page(request: Request, host_id: int): + return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id}) + + +@app.get("/rdp/{host_id}", response_class=HTMLResponse) +async def rdp_page(request: Request, host_id: int): + return templates.TemplateResponse(request, "rdp.html", {"host_id": host_id}) + + +@app.get("/healthz") +async def healthz(): + return {"status": "ok"} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/schemas.py b/app/models/schemas.py new file mode 100644 index 0000000..67e38e7 --- /dev/null +++ b/app/models/schemas.py @@ -0,0 +1,109 @@ +""" +Pydantic-Schemas fuer alle API-Eingaben/-Ausgaben. + +Strikte Validierung ist Teil des Hardening-Konzepts (6.6): Laenge, Typ und +erlaubte Zeichen werden hier durchgesetzt, bevor irgendein Wert die +Business-Logik oder die Datenbank erreicht. +""" +from __future__ import annotations + +import re +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$") +HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$") + + +class LoginRequest(BaseModel): + username: str = Field(min_length=3, max_length=64) + password: str = Field(min_length=1, max_length=256) + + @field_validator("username") + @classmethod + def check_username(cls, v: str) -> str: + if not USERNAME_RE.match(v): + raise ValueError("Ungueltiger Benutzername") + return v + + +class TotpLoginRequest(BaseModel): + pending_token: str + code: str = Field(min_length=6, max_length=64) + + +class TotpConfirmRequest(BaseModel): + code: str = Field(min_length=6, max_length=6, pattern=r"^\d{6}$") + + +class ChangePasswordRequest(BaseModel): + current_password: str = Field(min_length=1, max_length=256) + new_password: str = Field(min_length=12, max_length=256) + + +class UserCreateRequest(BaseModel): + username: str = Field(min_length=3, max_length=64) + initial_password: str = Field(min_length=12, max_length=256) + is_admin: bool = False + + @field_validator("username") + @classmethod + def check_username(cls, v: str) -> str: + if not USERNAME_RE.match(v): + raise ValueError("Ungueltiger Benutzername") + return v + + +class HostGroupCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=128) + description: str | None = Field(default=None, max_length=1024) + + +class HostCreateRequest(BaseModel): + host_group_id: int + hostname: str = Field(min_length=1, max_length=128) + address: str = Field(min_length=1, max_length=255) + protocol: Literal["ssh", "rdp"] + port: int = Field(gt=0, le=65535) + os_type: Literal["linux", "windows"] + ssh_host_key_fingerprint: str | None = Field(default=None, max_length=512) + ssh_username: str | None = Field(default=None, max_length=128) + rdp_username: str | None = Field(default=None, max_length=128) + rdp_domain: str | None = Field(default=None, max_length=128) + rdp_require_nla: bool = True + clipboard_enabled: bool = True + file_transfer_enabled: bool = True + + @field_validator("hostname") + @classmethod + def check_hostname(cls, v: str) -> str: + if not HOSTNAME_LABEL_RE.match(v): + raise ValueError("Ungueltiger Hostname") + return v + + +class RoleGrantRequest(BaseModel): + user_id: int + host_group_id: int + role_name: Literal[ + "ssh_connect", "rdp_connect", "file_transfer", "clipboard", + "session_recording_view", "admin_hostgroup", + ] + expires_at: str | None = None + + +class SshKeyCreateRequest(BaseModel): + label: str = Field(min_length=1, max_length=128) + owner_user_id: int | None = None + private_key_pem: str = Field(min_length=1, max_length=32_768) + public_key: str = Field(min_length=1, max_length=8192) + key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] + + +class ConnectRequest(BaseModel): + host_id: int + + +class RdpCredentialsRequest(BaseModel): + password: str = Field(min_length=1, max_length=512) diff --git a/app/rbac.py b/app/rbac.py new file mode 100644 index 0000000..9df6224 --- /dev/null +++ b/app/rbac.py @@ -0,0 +1,33 @@ +"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6).""" +from __future__ import annotations + +import aiosqlite + + +async def user_has_role( + conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str +) -> bool: + cursor = await conn.execute( + """ + SELECT 1 FROM user_hostgroup_roles uhr + JOIN roles r ON r.id = uhr.role_id + WHERE uhr.user_id = ? + AND uhr.host_group_id = ? + AND r.name = ? + AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) + LIMIT 1 + """, + (user_id, host_group_id, role_name), + ) + row = await cursor.fetchone() + return row is not None + + +async def user_has_role_for_host( + conn: aiosqlite.Connection, *, user_id: int, host_id: int, role_name: str +) -> bool: + cursor = await conn.execute("SELECT host_group_id FROM hosts WHERE id = ?", (host_id,)) + row = await cursor.fetchone() + if row is None: + return False + return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name) diff --git a/app/rdp_proxy/__init__.py b/app/rdp_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/rdp_proxy/guacd_client.py b/app/rdp_proxy/guacd_client.py new file mode 100644 index 0000000..3a00136 --- /dev/null +++ b/app/rdp_proxy/guacd_client.py @@ -0,0 +1,166 @@ +""" +Guacamole-Protokoll-Tunnel zu guacd (siehe Konzept 3, 4.3). + +guacd selbst spricht RDP zum Windows-Ziel; der Jumphost tauscht mit guacd nur +das textbasierte Guacamole-Protokoll aus (laengenpraefigierte Elemente, +Instruktionen durch ';' abgeschlossen). Zugangsdaten werden ausschliesslich +serverseitig in die "connect"-Instruktion eingefuegt -- der Browser sieht sie +nie (analog zum SSH-Keyhandling in app/ssh_proxy/proxy.py). + +Hinweis: Die exakten von guacd erwarteten RDP-Parameter (Namen/Reihenfolge) +haengen von der eingesetzten guacd/FreeRDP-Version ab. Diese Implementierung +fragt sie dynamisch per "args"-Instruktion ab (kein Hardcoding einer festen +Parameterliste) und ist daher robust gegen kleinere Versionsunterschiede -- +sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden. +""" +from __future__ import annotations + +import asyncio +import logging + +logger = logging.getLogger("jumphost.rdp_proxy.guacd") + + +class GuacamoleProtocolError(Exception): + pass + + +def encode_instruction(*elements: str) -> str: + parts = [] + for element in elements: + encoded = element.encode("utf-8") + parts.append(f"{len(encoded)}.{element}") + return ",".join(parts) + ";" + + +async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes: + buf = bytearray() + while True: + b = await reader.readexactly(1) + if b == delimiter: + return bytes(buf) + buf += b + + +async def read_instruction(reader: asyncio.StreamReader) -> list[str]: + elements: list[str] = [] + while True: + length_bytes = await _read_until(reader, b".") + try: + length = int(length_bytes) + except ValueError as exc: + raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc + content = (await reader.readexactly(length)).decode("utf-8") + elements.append(content) + sep = await reader.readexactly(1) + if sep == b";": + return elements + if sep != b",": + raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}") + + +def parse_instruction_text(text: str) -> list[str]: + """Parst genau EINE Instruktion aus einem bereits vollstaendig vorliegenden + String (z.B. eine einzelne WebSocket-Textnachricht vom Browser).""" + elements: list[str] = [] + i = 0 + n = len(text) + while i < n: + dot = text.index(".", i) + length = int(text[i:dot]) + start = dot + 1 + end = start + length + elements.append(text[start:end]) + sep = text[end] if end < n else "" + i = end + 1 + if sep == ";": + break + if sep != ",": + raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}") + return elements + + +class GuacdTunnel: + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None: + self.reader = reader + self.writer = writer + self.connection_id = connection_id + + async def close(self) -> None: + try: + self.writer.close() + await self.writer.wait_closed() + except Exception: + # Aufraeumpfad: ein bereits getrenntes/fehlerhaftes Socket beim + # Schliessen darf den Session-Teardown (Audit-Log-Eintrag, + # DB-Update in ws_tunnel.py) nicht verhindern. Bewusst breit + # gefangen, aber protokolliert statt stillschweigend verschluckt. + logger.debug("Fehler beim Schliessen des guacd-Tunnels (ignoriert)", exc_info=True) + + +async def open_tunnel( + *, + guacd_host: str, + guacd_port: int, + protocol: str, + params: dict[str, str], + screen_width: int = 1024, + screen_height: int = 768, + dpi: int = 96, +) -> GuacdTunnel: + reader, writer = await asyncio.open_connection(guacd_host, guacd_port) + + writer.write(encode_instruction("select", protocol).encode("utf-8")) + await writer.drain() + + args_instr = await read_instruction(reader) + if args_instr[0] != "args": + raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}") + arg_names = args_instr[1:] + + handshake = ( + encode_instruction("size", str(screen_width), str(screen_height), str(dpi)) + + encode_instruction("audio") + + encode_instruction("video") + + encode_instruction("image", "image/png", "image/jpeg") + ) + writer.write(handshake.encode("utf-8")) + await writer.drain() + + values = [params.get(name, "") for name in arg_names] + writer.write(encode_instruction("connect", *values).encode("utf-8")) + await writer.drain() + + ready_instr = await read_instruction(reader) + if ready_instr[0] != "ready": + raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}") + connection_id = ready_instr[1] if len(ready_instr) > 1 else "" + + return GuacdTunnel(reader, writer, connection_id) + + +def build_rdp_params(host: dict, password: str) -> dict[str, str]: + """Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz. + + Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern + rdp_require_nla gesetzt ist (Standard), Zertifikatspruefung ist standardmaessig + AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den + Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller + Bypass. + """ + return { + "hostname": host["address"], + "port": str(host["port"]), + "username": host.get("rdp_username") or "", + "password": password, + "domain": host.get("rdp_domain") or "", + "security": "nla" if host.get("rdp_require_nla", True) else "any", + "ignore-cert": "false", + "disable-audio": "true", + "enable-drive": "true" if host.get("file_transfer_enabled") else "false", + "drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}", + "create-drive-path": "true", + "disable-copy": "false" if host.get("clipboard_enabled") else "true", + "disable-paste": "false" if host.get("clipboard_enabled") else "true", + "resize-method": "display-update", + } diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py new file mode 100644 index 0000000..c622318 --- /dev/null +++ b/app/rdp_proxy/ws_tunnel.py @@ -0,0 +1,170 @@ +""" +WebSocket-Bruecke Browser (guacamole-common-js) <-> guacd (Konzept 4.3). + +Setzt pro Hostgruppe/Host konfigurierbare Policies durch, die guacd selbst +zwar schon per connect-Parameter bekommt (disable-copy/-paste, enable-drive), +zusaetzlich werden Clipboard-Instruktionen aber auch hier auf Protokollebene +gefiltert -- Defense-in-Depth, falls sich guacd-Parameter je nach Version +unterscheiden. +""" +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from app.auth.deps import get_current_user_ws +from app.config import settings +from app.db import get_db +from app.rbac import user_has_role_for_host +from app.recordings.recorder import SessionRecorder +from app.security.audit import write_audit_event +from app.security.crypto import decrypt_secret +from app.rdp_proxy.guacd_client import ( + GuacamoleProtocolError, + build_rdp_params, + encode_instruction, + open_tunnel, + parse_instruction_text, + read_instruction, +) +from app.ssh_proxy.proxy import HostNotConfiguredError, load_host + +logger = logging.getLogger("jumphost.rdp_proxy.ws") +router = APIRouter() + + +async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None: + while True: + instr = await read_instruction(tunnel.reader) + text = encode_instruction(*instr) + recorder.record("output", text) + await websocket.send_text(text) + + +async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool) -> None: + while True: + message = await websocket.receive_text() + try: + instr = parse_instruction_text(message) + except GuacamoleProtocolError: + continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen + + if not clipboard_enabled and instr and instr[0] == "clipboard": + continue # Defense-in-Depth: Clipboard serverseitig blocken + + recorder.record("input", message) + tunnel.writer.write(message.encode("utf-8")) + await tunnel.writer.drain() + + +@router.websocket("/ws/rdp/{host_id}") +async def rdp_tunnel( + websocket: WebSocket, + host_id: int, + width: int = Query(default=1280, ge=320, le=7680), + height: int = Query(default=800, ge=240, le=4320), + dpi: int = Query(default=96, ge=48, le=384), +): + user = await get_current_user_ws(websocket) + if user is None: + await websocket.close(code=4401) + return + + conn = get_db() + if not user.is_admin and not await user_has_role_for_host( + conn, user_id=user.id, host_id=host_id, role_name="rdp_connect" + ): + await websocket.close(code=4403) + return + + await websocket.accept() + client_ip = websocket.client.host if websocket.client else "unknown" + + try: + host = await load_host(conn, host_id) + except HostNotConfiguredError as exc: + await websocket.close(code=4404) + return + + if host["protocol"] != "rdp": + await websocket.close(code=4400) + return + + cred_cursor = await conn.execute( + "SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,) + ) + cred_row = await cred_cursor.fetchone() + if cred_row is None: + await websocket.close(code=4404) + return + password = decrypt_secret(cred_row[0], associated_data=b"rdp_password") + + cursor = await conn.execute( + "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'rdp', ?)", + (user.id, host_id, client_ip), + ) + session_id = cursor.lastrowid + recorder = SessionRecorder(session_id) + await conn.execute( + "UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id) + ) + await write_audit_event( + conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip, + details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id}, + ) + await conn.commit() + + end_reason = "logout" + tunnel = None + tasks: list[asyncio.Task] = [] + try: + params = build_rdp_params(host, password.decode()) + tunnel = await open_tunnel( + guacd_host=settings.guacd_host, guacd_port=settings.guacd_port, + protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi, + ) + clipboard_enabled = bool(host.get("clipboard_enabled", True)) + tasks = [ + asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)), + asyncio.create_task( + _ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled) + ), + ] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) + for task in pending: + task.cancel() + for task in done: + exc = task.exception() + if exc: + raise exc + except WebSocketDisconnect: + end_reason = "logout" + except (GuacamoleProtocolError, ConnectionError, OSError) as exc: + logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc) + end_reason = "error" + finally: + del password # Klartext-Passwort so schnell wie moeglich freigeben + for task in tasks: + task.cancel() + if tunnel: + await tunnel.close() + recorder.close() + await conn.execute( + "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " + "WHERE id = ?", + (end_reason, session_id), + ) + await write_audit_event( + conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip, + details={"host_id": host_id, "session_id": session_id, "reason": end_reason}, + ) + await conn.commit() + try: + await websocket.close() + except Exception: + # Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind + # zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client + # getrenntes WebSocket darf das nicht rueckwirkend fehlschlagen lassen. + logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True) diff --git a/app/recordings/__init__.py b/app/recordings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/recordings/recorder.py b/app/recordings/recorder.py new file mode 100644 index 0000000..cf323cc --- /dev/null +++ b/app/recordings/recorder.py @@ -0,0 +1,63 @@ +""" +Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5). + +Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir. +Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das +Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der +Aufzeichnung erkennbar ist. +""" +from __future__ import annotations + +import hashlib +import json +import time +from pathlib import Path + +from app.config import settings + +GENESIS_HASH = "0" * 64 + + +class SessionRecorder: + def __init__(self, session_id: int) -> None: + self.session_id = session_id + self.path = settings.recordings_dir / f"session_{session_id}.jsonl" + self._prev_hash = GENESIS_HASH + self._start_ts = time.time() + self._fh = open(self.path, "a", encoding="utf-8") + try: + self.path.chmod(0o600) + except OSError: + pass + + def record(self, direction: str, data: str) -> None: + """direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe).""" + offset = round(time.time() - self._start_ts, 4) + entry = {"t": offset, "dir": direction, "data": data} + entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True) + entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest() + line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash}) + self._fh.write(line + "\n") + self._fh.flush() + self._prev_hash = entry_hash + + def close(self) -> None: + if not self._fh.closed: + self._fh.close() + + +def verify_recording(path: Path) -> bool: + prev_hash = GENESIS_HASH + with open(path, encoding="utf-8") as fh: + for line in fh: + if not line.strip(): + continue + row = json.loads(line) + if row["prev_hash"] != prev_hash: + return False + entry_json = json.dumps(row["entry"], ensure_ascii=False, sort_keys=True) + expected = hashlib.sha256((prev_hash + "|" + entry_json).encode()).hexdigest() + if expected != row["hash"]: + return False + prev_hash = row["hash"] + return True diff --git a/app/security/__init__.py b/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/security/audit.py b/app/security/audit.py new file mode 100644 index 0000000..f6959cc --- /dev/null +++ b/app/security/audit.py @@ -0,0 +1,70 @@ +""" +Manipulationssicheres, hash-verkettetes Audit-Log (siehe Konzept 4.7 / 6.1). + +Jeder Eintrag verkettet sich kryptographisch mit seinem Vorgaenger: + entry_hash = sha256(prev_hash || ts || event_type || details_json) + +Nachtraegliches Aendern oder Herausloeschen eines Eintrags bricht die Kette +ab dieser Stelle - erkennbar durch verify_chain(). Zusaetzlich verhindern +DB-Trigger (0001_initial.sql) UPDATE/DELETE auf Anwendungsebene. +""" +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any + +import aiosqlite + +GENESIS_HASH = "0" * 64 + + +def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) -> str: + payload = f"{prev_hash}|{ts}|{event_type}|{details_json}".encode() + return hashlib.sha256(payload).hexdigest() + + +async def write_audit_event( + conn: aiosqlite.Connection, + *, + event_type: str, + user_id: int | None, + client_ip: str | None, + details: dict[str, Any], +) -> None: + """Schreibt einen Audit-Eintrag; haengt ihn an die bestehende Hash-Chain an. + + Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder + zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt. + """ + cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1") + row = await cursor.fetchone() + prev_hash = row[0] if row else GENESIS_HASH + + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + details_json = json.dumps(details, sort_keys=True, ensure_ascii=False) + entry_hash = _entry_hash(prev_hash, ts, event_type, details_json) + + await conn.execute( + "INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash), + ) + + +async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]: + """Prueft die gesamte Audit-Log-Kette. Rueckgabe: (intakt?, erste kaputte id).""" + prev_hash = GENESIS_HASH + cursor = await conn.execute( + "SELECT id, ts, event_type, details_json, prev_hash, entry_hash FROM audit_log ORDER BY id ASC" + ) + async for row in cursor: + entry_id, ts, event_type, details_json, stored_prev, stored_entry = row + if stored_prev != prev_hash: + return False, entry_id + expected = _entry_hash(prev_hash, ts, event_type, details_json) + if expected != stored_entry: + return False, entry_id + prev_hash = stored_entry + return True, None diff --git a/app/security/av_scan.py b/app/security/av_scan.py new file mode 100644 index 0000000..e810d51 --- /dev/null +++ b/app/security/av_scan.py @@ -0,0 +1,38 @@ +""" +AV-Scan-Hook fuer Datei-Uploads (Konzept 4.3/6.6). + +Bewusst als duenner Wrapper um ein optionales ClamAV (clamd) gehalten: ist +kein Scanner konfiguriert/erreichbar, wird das Ergebnis "skipped" vermerkt +statt die Datei stillschweigend als "sauber" zu markieren -- Admins sehen im +Audit-/Filetransfer-Log damit ehrlich, ob wirklich gescannt wurde. +""" +from __future__ import annotations + +import shutil +import subprocess # nosec B404 -- benoetigt fuer den optionalen ClamAV-Aufruf, siehe unten + + +def scan_bytes(data: bytes) -> str: + """Rueckgabe: 'clean', 'infected:' oder 'skipped:'.""" + clamdscan = shutil.which("clamdscan") + if not clamdscan: + return "skipped:clamdscan_not_installed" + try: + # Argumentliste ist vollstaendig fest (kein shell=True, keine + # Nutzereingabe im Kommando selbst); die hochgeladenen Datei-Bytes + # werden ausschliesslich ueber stdin (input=data) uebergeben, nie als + # Kommandozeilen-/Pfadargument -- Command-Injection ueber Dateinamen + # o.ae. ist damit ausgeschlossen. + proc = subprocess.run( # nosec B603 + [clamdscan, "--stdout", "--no-summary", "-"], + input=data, capture_output=True, timeout=30, + ) + except (subprocess.TimeoutExpired, OSError): + return "skipped:scan_error" + + output = proc.stdout.decode(errors="replace") + if proc.returncode == 0: + return "clean" + if proc.returncode == 1: + return f"infected:{output.strip()}" + return "skipped:scan_error" diff --git a/app/security/crypto.py b/app/security/crypto.py new file mode 100644 index 0000000..23462c9 --- /dev/null +++ b/app/security/crypto.py @@ -0,0 +1,32 @@ +""" +AES-256-GCM Verschluesselung fuer Secrets at rest (SSH-Private-Keys, TOTP-Secrets). + +Prinzip (siehe Konzept 6.4): Der Key-Encryption-Key (KEK) liegt NICHT in der +Datenbank, sondern kommt aus app.config.settings (systemd-creds/Env). Jeder +verschluesselte Datensatz erhaelt einen frischen, zufaelligen Nonce; Nonce + +Ciphertext + Auth-Tag werden gemeinsam gespeichert. +""" +from __future__ import annotations + +import os + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from app.config import settings + +NONCE_LEN = 12 # 96 Bit, empfohlene GCM-Noncelaenge + + +def encrypt_secret(plaintext: bytes, *, associated_data: bytes = b"") -> bytes: + """Verschluesselt plaintext mit dem globalen KEK. Rueckgabe: nonce || ciphertext.""" + aesgcm = AESGCM(settings.kek) + nonce = os.urandom(NONCE_LEN) + ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data or None) + return nonce + ciphertext + + +def decrypt_secret(blob: bytes, *, associated_data: bytes = b"") -> bytes: + """Entschluesselt einen mit encrypt_secret() erzeugten Blob.""" + aesgcm = AESGCM(settings.kek) + nonce, ciphertext = blob[:NONCE_LEN], blob[NONCE_LEN:] + return aesgcm.decrypt(nonce, ciphertext, associated_data or None) diff --git a/app/security/passwords.py b/app/security/passwords.py new file mode 100644 index 0000000..9f94b36 --- /dev/null +++ b/app/security/passwords.py @@ -0,0 +1,28 @@ +"""Argon2id-Passwort-Hashing (siehe Konzept 6.2).""" +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, InvalidHash + +# Parameter angelehnt an aktuelle OWASP-Empfehlung; in Produktion je nach +# Server-Hardware kalibrieren (siehe Konzept 6.2). +_hasher = PasswordHasher(time_cost=2, memory_cost=19 * 1024, parallelism=1) + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + _hasher.verify(password_hash, password) + except (VerifyMismatchError, InvalidHash): + return False + return True + + +def needs_rehash(password_hash: str) -> bool: + try: + return _hasher.check_needs_rehash(password_hash) + except InvalidHash: + return True diff --git a/app/security/pending_totp.py b/app/security/pending_totp.py new file mode 100644 index 0000000..9336e67 --- /dev/null +++ b/app/security/pending_totp.py @@ -0,0 +1,29 @@ +"""Kurzlebige, signierte Tokens fuer den Zwischenschritt Passwort -> TOTP. + +Es wird bewusst KEIN Session-Cookie ausgestellt, solange der zweite Faktor +nicht bestaetigt ist (siehe Konzept 6.2: "kein Login ohne TOTP moeglich"). +""" +from __future__ import annotations + +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from app.config import settings + +_PENDING_MAX_AGE_S = 300 # 5 Minuten Zeitfenster fuer den TOTP-Schritt + +_serializer = URLSafeTimedSerializer(settings.session_secret.hex(), salt="jumphost-pending-totp") + + +def create_pending_token(user_id: int) -> str: + return _serializer.dumps({"uid": user_id}) + + +def decode_pending_token(token: str) -> int | None: + try: + data = _serializer.loads(token, max_age=_PENDING_MAX_AGE_S) + except (BadSignature, SignatureExpired): + return None + try: + return int(data["uid"]) + except (KeyError, TypeError, ValueError): + return None diff --git a/app/security/rate_limit.py b/app/security/rate_limit.py new file mode 100644 index 0000000..576df54 --- /dev/null +++ b/app/security/rate_limit.py @@ -0,0 +1,34 @@ +""" +Einfacher In-Memory Rate-Limiter fuer den Login-Endpunkt (pro Quell-IP). + +Fuer einen Single-Process-ASGI-Deployment (siehe Konzept: kleine/mittlere +Umgebung) ausreichend. Bei horizontaler Skalierung auf mehrere Prozesse/Hosts +muss dies durch einen geteilten Store (z.B. Redis) ersetzt werden -- als +Erweiterungspunkt bewusst hinter einer kleinen Klasse gekapselt. +""" +from __future__ import annotations + +import time +from collections import defaultdict, deque + + +class SlidingWindowRateLimiter: + def __init__(self, max_events: int, window_seconds: int) -> None: + self.max_events = max_events + self.window_seconds = window_seconds + self._events: dict[str, deque[float]] = defaultdict(deque) + + def allow(self, key: str) -> bool: + now = time.time() + window = self._events[key] + while window and now - window[0] > self.window_seconds: + window.popleft() + if len(window) >= self.max_events: + return False + window.append(now) + return True + + +# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den +# Account-basierten Lockout in app/auth/routes.py. +login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60) diff --git a/app/security/sessions.py b/app/security/sessions.py new file mode 100644 index 0000000..b5f3c89 --- /dev/null +++ b/app/security/sessions.py @@ -0,0 +1,70 @@ +""" +Signierte, serverseitig invalidierbare Session-Cookies. + +Kein separates Session-Store noetig: Das Cookie traegt user_id, +session_version (fuer harte Invalidierung, z.B. bei Passwortwechsel) und +zwei Zeitstempel (Login-Zeit fuer den absoluten Timeout, Last-Seen fuer den +gleitenden Idle-Timeout). Signatur ueber einen vom KEK getrennten Secret +(Schluesseltrennung, Konzept 6.2). +""" +from __future__ import annotations + +import time +from dataclasses import dataclass + +from itsdangerous import BadSignature, URLSafeSerializer + +from app.config import settings + +_serializer = URLSafeSerializer(settings.session_secret.hex(), salt="jumphost-session") + +SESSION_COOKIE_NAME = "jh_session" + + +@dataclass +class SessionPayload: + user_id: int + session_version: int + login_ts: float + last_seen_ts: float + + +def create_session_token(user_id: int, session_version: int) -> str: + now = time.time() + payload = {"uid": user_id, "sv": session_version, "iat": now, "seen": now} + return _serializer.dumps(payload) + + +def refresh_session_token(payload: SessionPayload) -> str: + data = { + "uid": payload.user_id, + "sv": payload.session_version, + "iat": payload.login_ts, + "seen": time.time(), + } + return _serializer.dumps(data) + + +def decode_session_token(token: str) -> SessionPayload | None: + try: + data = _serializer.loads(token) + except BadSignature: + return None + try: + return SessionPayload( + user_id=int(data["uid"]), + session_version=int(data["sv"]), + login_ts=float(data["iat"]), + last_seen_ts=float(data["seen"]), + ) + except (KeyError, TypeError, ValueError): + return None + + +def is_expired(payload: SessionPayload) -> bool: + now = time.time() + if now - payload.last_seen_ts > settings.session_idle_timeout_s: + return True + if now - payload.login_ts > settings.session_absolute_timeout_s: + return True + return False diff --git a/app/security/totp.py b/app/security/totp.py new file mode 100644 index 0000000..f87124c --- /dev/null +++ b/app/security/totp.py @@ -0,0 +1,52 @@ +""" +TOTP-Enrollment und -Verifikation (RFC 6238) inkl. Recovery-Codes. + +Pflicht-2FA: siehe Konzept 4.5 / 6.2. Das TOTP-Secret wird mit einem eigenen +AAD-Kontext ("totp") verschluesselt gespeichert -- Schluesseltrennung vom +SSH-Key-Material ist ueber den associated_data-Parameter realisiert (beide +nutzen zwar denselben KEK, sind aber durch AAD kontextgebunden und nicht +gegeneinander austauschbar). +""" +from __future__ import annotations + +import hashlib +import secrets + +import pyotp + +from app.security.crypto import decrypt_secret, encrypt_secret + +_TOTP_AAD = b"totp_secret" + + +def generate_totp_secret() -> str: + return pyotp.random_base32() + + +def encrypt_totp_secret(secret: str) -> bytes: + return encrypt_secret(secret.encode(), associated_data=_TOTP_AAD) + + +def decrypt_totp_secret(blob: bytes) -> str: + return decrypt_secret(blob, associated_data=_TOTP_AAD).decode() + + +def provisioning_uri(secret: str, username: str, issuer: str = "Jumphost") -> str: + return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer) + + +def verify_totp_code(secret: str, code: str) -> bool: + """Verifiziert mit +-1 Zeitfenster Toleranz gegen Clock-Drift.""" + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=1) + + +def generate_recovery_codes(count: int = 10) -> list[str]: + """Erzeugt Einmal-Recovery-Codes im Klartext (nur zur einmaligen Anzeige).""" + return [secrets.token_hex(5) for _ in range(count)] + + +def hash_recovery_code(code: str) -> str: + # Recovery-Codes sind hochentropisch (40 Bit hex) -- ein schneller, + # gesalzener Hash reicht hier aus; dennoch SHA-256 mit Pfeffer aus KEK-Kontext. + return hashlib.sha256(code.encode() + b"recovery_code_pepper").hexdigest() diff --git a/app/ssh_proxy/__init__.py b/app/ssh_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ssh_proxy/proxy.py b/app/ssh_proxy/proxy.py new file mode 100644 index 0000000..3e65d8f --- /dev/null +++ b/app/ssh_proxy/proxy.py @@ -0,0 +1,130 @@ +""" +Serverseitiger SSH-Verbindungsaufbau (asyncssh). + +Zentrales Sicherheitsprinzip (Konzept 4.2/4.4/6.4): der private Schluessel +wird pro Verbindung aus der DB geladen, entschluesselt, an asyncssh +uebergeben und danach nicht weiter referenziert -- er verlaesst den +Serverprozess nie und wird nicht geloggt. Strict Host Key Checking ist +Pflicht: ohne gepinnten Fingerprint wird die Verbindung abgelehnt. +""" +from __future__ import annotations + +import logging + +import asyncssh +import aiosqlite + +from app.security.crypto import decrypt_secret + +logger = logging.getLogger("jumphost.ssh_proxy") + + +class HostNotConfiguredError(Exception): + pass + + +class HostKeyMismatchError(Exception): + def __init__(self, expected: str | None, observed: str | None) -> None: + self.expected = expected + self.observed = observed + super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}") + + +class _PinnedHostKeyClient(asyncssh.SSHClient): + """Erzwingt Strict Host Key Checking gegen einen fest hinterlegten + SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU).""" + + def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None: + self.expected_fingerprint = expected_fingerprint + self.discovery_mode = discovery_mode + self.observed_fingerprint: str | None = None + + def validate_host_public_key(self, host, addr, port, key) -> bool: # noqa: D102 + self.observed_fingerprint = key.get_fingerprint("sha256") + if self.discovery_mode: + # Nur ueber den expliziten Admin-Discovery-Endpunkt erreichbar, + # niemals im regulaeren Verbindungspfad (siehe admin/routes.py). + return True + if not self.expected_fingerprint: + return False + return self.observed_fingerprint == self.expected_fingerprint + + +async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: + cursor = await conn.execute( + "SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, " + "ssh_username, file_transfer_enabled, host_group_id FROM hosts WHERE id = ? AND is_active = 1", + (host_id,), + ) + row = await cursor.fetchone() + if row is None: + raise HostNotConfiguredError(f"Host {host_id} nicht gefunden oder inaktiv") + keys = ( + "id", "hostname", "address", "port", "os_type", "protocol", + "ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id", + ) + return dict(zip(keys, row)) + + +async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: + cursor = await conn.execute( + "SELECT sk.private_key_enc FROM ssh_keys sk " + "JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id " + "WHERE m.host_id = ? LIMIT 1", + (host_id,), + ) + row = await cursor.fetchone() + if row is None: + raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") + pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") + try: + return asyncssh.import_private_key(pem) + finally: + # Bestpraxis: Referenz auf den Klartext-PEM-Bytes so schnell wie moeglich loslassen. + del pem + + +async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection: + host = await load_host(conn, host_id) + if host["protocol"] != "ssh": + raise HostNotConfiguredError("Host ist kein SSH-Ziel") + + private_key = await load_private_key_for_host(conn, host_id) + client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"]) + + try: + connection = await asyncssh.connect( + host["address"], + port=host["port"], + username=host["ssh_username"], + client_keys=[private_key], + known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key + client_factory=client_factory, + connect_timeout=10, + ) + except asyncssh.Error as exc: + logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc) + raise + return connection + + +async def discover_and_store_host_key( + conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int +) -> str: + """Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen + und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only + Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung + und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).""" + host = await load_host(conn, host_id) + client = _PinnedHostKeyClient(None, discovery_mode=True) + connection = await asyncssh.connect( + host["address"], port=host["port"], username=host["ssh_username"], + known_hosts=None, client_factory=lambda: client, connect_timeout=10, + ) + connection.close() + fingerprint = client.observed_fingerprint + await conn.execute( + "UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id) + ) + await conn.commit() + return fingerprint diff --git a/app/ssh_proxy/sftp.py b/app/ssh_proxy/sftp.py new file mode 100644 index 0000000..d2167ab --- /dev/null +++ b/app/ssh_proxy/sftp.py @@ -0,0 +1,145 @@ +"""Dateitransfer zu SSH-Zielen per SFTP (Upload/Download ueber den Jumphost). + +Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept +6.6). Jeder Transfer wird in file_transfers + audit_log protokolliert. +""" +from __future__ import annotations + +import hashlib + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status +from fastapi.responses import StreamingResponse + +from app.auth.deps import CurrentUser, get_current_user +from app.db import get_db +from app.rbac import user_has_role_for_host +from app.security.audit import write_audit_event +from app.security.av_scan import scan_bytes +from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host + +router = APIRouter(prefix="/ssh", tags=["file-transfer"]) + +MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept) + + +def _client_ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +async def _require_file_transfer(host_id: int, request: Request, user: CurrentUser = Depends(get_current_user)): + conn = get_db() + if not user.is_admin and not await user_has_role_for_host( + conn, user_id=user.id, host_id=host_id, role_name="file_transfer" + ): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Filetransfer-Berechtigung fuer diesen Host") + host = await load_host(conn, host_id) + if not host["file_transfer_enabled"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Dateitransfer ist fuer diesen Host deaktiviert") + return host + + +async def _log_transfer(conn, *, user: CurrentUser, host_id: int, client_ip: str, direction: str, + filename: str, size: int, sha256: str, av_result: str) -> None: + cursor = await conn.execute( + "INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) " + "VALUES (?, ?, 'ssh', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'file_transfer')", + (user.id, host_id, client_ip), + ) + session_id = cursor.lastrowid + await conn.execute( + "INSERT INTO file_transfers (session_id, direction, filename, size_bytes, sha256, av_scan_result) " + "VALUES (?, ?, ?, ?, ?, ?)", + (session_id, direction, filename, size, sha256, av_result), + ) + await write_audit_event( + conn, event_type="file_transfer", user_id=user.id, client_ip=client_ip, + details={ + "host_id": host_id, "direction": direction, "filename": filename, + "size_bytes": size, "sha256": sha256, "av_scan_result": av_result, + }, + ) + await conn.commit() + + +@router.post("/{host_id}/files/upload") +async def upload_file( + host_id: int, + request: Request, + remote_path: str = Query(..., max_length=1024), + file: UploadFile = ..., + host=Depends(_require_file_transfer), + user: CurrentUser = Depends(get_current_user), +): + data = await file.read(MAX_UPLOAD_BYTES + 1) + if len(data) > MAX_UPLOAD_BYTES: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross") + + av_result = scan_bytes(data) + if av_result.startswith("infected"): + conn = get_db() + await write_audit_event( + conn, event_type="file_transfer_blocked_malware", user_id=user.id, + client_ip=_client_ip(request), + details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result}, + ) + await conn.commit() + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}") + + sha256 = hashlib.sha256(data).hexdigest() + conn = get_db() + try: + ssh_conn = await connect_to_host(conn, host_id) + try: + async with ssh_conn.start_sftp_client() as sftp: + async with sftp.open(remote_path, "wb") as remote_file: + await remote_file.write(data) + finally: + ssh_conn.close() + except HostNotConfiguredError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + + await _log_transfer( + conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload", + filename=file.filename or remote_path, size=len(data), sha256=sha256, av_scan_result=av_result, + ) + return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result} + + +@router.get("/{host_id}/files/download") +async def download_file( + host_id: int, + request: Request, + remote_path: str = Query(..., max_length=1024), + host=Depends(_require_file_transfer), + user: CurrentUser = Depends(get_current_user), +): + conn = get_db() + try: + ssh_conn = await connect_to_host(conn, host_id) + try: + async with ssh_conn.start_sftp_client() as sftp: + stat = await sftp.stat(remote_path) + if stat.size and stat.size > MAX_UPLOAD_BYTES: + raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross") + async with sftp.open(remote_path, "rb") as remote_file: + data = await remote_file.read() + finally: + ssh_conn.close() + except HostNotConfiguredError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + + sha256 = hashlib.sha256(data).hexdigest() + filename = remote_path.rsplit("/", 1)[-1] + await _log_transfer( + conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download", + filename=filename, size=len(data), sha256=sha256, av_scan_result="not_applicable_download", + ) + + def _iter(): + yield data + + return StreamingResponse( + _iter(), + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/app/ssh_proxy/terminal_ws.py b/app/ssh_proxy/terminal_ws.py new file mode 100644 index 0000000..19e40fe --- /dev/null +++ b/app/ssh_proxy/terminal_ws.py @@ -0,0 +1,144 @@ +""" +Browser-Terminal <-> SSH-Ziel per WebSocket (xterm.js-kompatibel). + +Framing: JSON-Textframes. + Client -> Server: {"type":"input","data":""} | {"type":"resize","cols":n,"rows":n} + Server -> Client: {"type":"output","data":""} | {"type":"error","message":"..."} | {"type":"closed"} + +Jede Session wird aufgezeichnet (app.recordings.recorder) und im Audit-Log +mit Start/Ende vermerkt (Konzept 4.2, 4.7, 6.5). +""" +from __future__ import annotations + +import asyncio +import base64 +import logging + +import asyncssh +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from app.auth.deps import get_current_user_ws +from app.db import get_db +from app.rbac import user_has_role_for_host +from app.recordings.recorder import SessionRecorder +from app.security.audit import write_audit_event +from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host + +logger = logging.getLogger("jumphost.ssh_proxy.ws") +router = APIRouter() + +MAX_SESSION_SECONDS = 8 * 3600 +IDLE_TIMEOUT_SECONDS = 15 * 60 + + +async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder): + try: + while True: + data = await process.stdout.read(65536) + if not data: + break + if isinstance(data, str): + data = data.encode("utf-8", errors="replace") + recorder.record("output", base64.b64encode(data).decode()) + await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()}) + except (asyncssh.Error, ConnectionResetError): + pass + + +@router.websocket("/ws/ssh/{host_id}") +async def ssh_terminal(websocket: WebSocket, host_id: int): + user = await get_current_user_ws(websocket) + if user is None: + await websocket.close(code=4401) + return + + conn = get_db() + if not user.is_admin and not await user_has_role_for_host( + conn, user_id=user.id, host_id=host_id, role_name="ssh_connect" + ): + await websocket.close(code=4403) + return + + await websocket.accept() + client_ip = websocket.client.host if websocket.client else "unknown" + + try: + host = await load_host(conn, host_id) + except HostNotConfiguredError as exc: + await websocket.send_json({"type": "error", "message": str(exc)}) + await websocket.close(code=4404) + return + + cursor = await conn.execute( + "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', ?)", + (user.id, host_id, client_ip), + ) + session_id = cursor.lastrowid + recorder = SessionRecorder(session_id) + await conn.execute( + "UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id) + ) + await write_audit_event( + conn, event_type="ssh_session_start", user_id=user.id, client_ip=client_ip, + details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id}, + ) + await conn.commit() + + end_reason = "logout" + ssh_conn = None + process = None + pump_task = None + try: + ssh_conn = await connect_to_host(conn, host_id) + process = await ssh_conn.create_process(term_type="xterm-256color") + pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder)) + + while True: + try: + msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS) + except asyncio.TimeoutError: + end_reason = "idle_timeout" + break + + if msg.get("type") == "input": + raw = base64.b64decode(msg.get("data", "")) + recorder.record("input", base64.b64encode(raw).decode()) + process.stdin.write(raw.decode("utf-8", errors="replace")) + elif msg.get("type") == "resize": + cols, rows = int(msg.get("cols", 80)), int(msg.get("rows", 24)) + process.change_terminal_size(cols, rows) + except WebSocketDisconnect: + end_reason = "logout" + except asyncssh.Error as exc: + logger.warning("SSH-Sessionfehler (session_id=%s): %s", session_id, exc) + end_reason = "error" + try: + await websocket.send_json({"type": "error", "message": "Verbindung zum Zielsystem fehlgeschlagen"}) + except Exception: + # Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client; + # der eigentliche Fehler ist bereits oben geloggt (logger.warning). + logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) + except HostNotConfiguredError: + end_reason = "error" + finally: + if pump_task: + pump_task.cancel() + if process: + process.close() + if ssh_conn: + ssh_conn.close() + recorder.close() + await conn.execute( + "UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? " + "WHERE id = ?", + (end_reason, session_id), + ) + await write_audit_event( + conn, event_type="ssh_session_end", user_id=user.id, client_ip=client_ip, + details={"host_id": host_id, "session_id": session_id, "reason": end_reason}, + ) + await conn.commit() + try: + await websocket.close() + except Exception: + logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True) diff --git a/jumphost.zip b/jumphost.zip index 0537552..4c8336d 100644 Binary files a/jumphost.zip and b/jumphost.zip differ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c8c9c75 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..56a7094 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest==9.1.1 +pytest-asyncio==1.4.0 +httpx==0.27.2 +bandit==1.9.4 +pip-audit==2.9.* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..97a1d18 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Lockfile-artig auf exakte, per pip-audit geprüfte Versionen gepinnt +# (Konzept 6.6: "Abhaengigkeiten fixiert, regelmaessiger Scan mit pip-audit"). +# Stand: pip-audit-Lauf vom 2026-08-19, 0 bekannte Schwachstellen. +fastapi==0.141.1 +starlette==1.6.0 +uvicorn[standard]==0.32.1 +aiosqlite==0.20.0 +asyncssh==2.18.0 +argon2-cffi==23.1.0 +pyotp==2.9.0 +qrcode[pil]==7.4.2 +pydantic==2.9.2 +jinja2==3.1.6 +python-multipart==0.0.32 +cryptography==50.0.0 +itsdangerous==2.2.0 +python-magic==0.4.27 diff --git a/scripts/create_admin.py b/scripts/create_admin.py new file mode 100644 index 0000000..21623bc --- /dev/null +++ b/scripts/create_admin.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Legt einmalig den ersten Admin-User an (interaktive Passwortabfrage). + +Aufruf (nach Deployment, siehe ansible/roles/sqlite_init): + /bin/python scripts/create_admin.py --username admin +""" +from __future__ import annotations + +import argparse +import asyncio +import getpass +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app.db import close_db, get_db, init_db # noqa: E402 +from app.security.passwords import hash_password # noqa: E402 + + +async def main(username: str) -> None: + await init_db() + conn = get_db() + + cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)) + if await cursor.fetchone() is not None: + print(f"Benutzer '{username}' existiert bereits.", file=sys.stderr) + await close_db() + sys.exit(1) + + password = getpass.getpass("Initiales Passwort (min. 12 Zeichen): ") + if len(password) < 12: + print("Passwort zu kurz.", file=sys.stderr) + await close_db() + sys.exit(1) + confirm = getpass.getpass("Passwort wiederholen: ") + if password != confirm: + print("Passwoerter stimmen nicht ueberein.", file=sys.stderr) + await close_db() + sys.exit(1) + + await conn.execute( + "INSERT INTO users (username, password_hash, is_admin, must_change_password) " + "VALUES (?, ?, 1, 1)", + (username, hash_password(password)), + ) + await conn.commit() + await close_db() + print(f"Admin-User '{username}' angelegt. TOTP-Einrichtung erfolgt beim ersten Login.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--username", required=True) + args = parser.parse_args() + asyncio.run(main(args.username)) diff --git a/scripts/fetch_frontend_assets.sh b/scripts/fetch_frontend_assets.sh new file mode 100644 index 0000000..f826668 --- /dev/null +++ b/scripts/fetch_frontend_assets.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Laedt xterm.js und guacamole-common-js lokal herunter (npm) und kopiert die +# fertigen Dist-Dateien nach static/js/vendor/ bzw. static/js/vendor/. +# +# Bewusst KEIN CDN-Bezug zur Laufzeit im Browser (Hardening-Konzept 4.1/6.6) -- +# der Download passiert einmalig zur Build-/Deployzeit, im Betrieb werden die +# Assets ausschliesslich lokal vom Jumphost selbst ausgeliefert. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +VENDOR_DIR="$PROJECT_ROOT/static/js/vendor" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +mkdir -p "$VENDOR_DIR" + +echo "==> Installiere xterm.js + Addon 'fit' + guacamole-common-js nach $TMP_DIR" +cd "$TMP_DIR" +npm init -y >/dev/null +npm install --no-audit --no-fund \ + xterm@5 \ + xterm-addon-fit@0.8 \ + guacamole-common-js@1.5 + +echo "==> Kopiere Dist-Dateien nach $VENDOR_DIR" +cp node_modules/xterm/lib/xterm.js "$VENDOR_DIR/xterm.js" +cp node_modules/xterm/css/xterm.css "$VENDOR_DIR/xterm.css" +cp node_modules/xterm-addon-fit/lib/xterm-addon-fit.js "$VENDOR_DIR/xterm-addon-fit.js" + +# guacamole-common-js liefert nur ESM/CJS-Builds aus, keinen fertigen +# Browser-Global-Build. Fuer die Einbindung per klassischem + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..9fd588d --- /dev/null +++ b/templates/login.html @@ -0,0 +1,46 @@ + + + + + + Jumphost Login + + + +
+
+

Jumphost Anmeldung

+ +
+
+ + + + +
+ + + + + + + + +
+
+
+
+ + + + diff --git a/templates/rdp.html b/templates/rdp.html new file mode 100644 index 0000000..6e45081 --- /dev/null +++ b/templates/rdp.html @@ -0,0 +1,23 @@ + + + + + + RDP-Session + + + +
+
+ + + Verbinde ... +
+
+
+ + + + + + diff --git a/templates/terminal.html b/templates/terminal.html new file mode 100644 index 0000000..c95f98d --- /dev/null +++ b/templates/terminal.html @@ -0,0 +1,27 @@ + + + + + + SSH-Terminal + + + + +
+
+ + + + + Verbinde ... +
+
+
+ + + + + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c19a379 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +import os +import secrets +import sys +from pathlib import Path + +os.environ.setdefault("JUMPHOST_ENV", "development") +os.environ.setdefault("JUMPHOST_DEV_KEK", secrets.token_hex(32)) +os.environ.setdefault("JUMPHOST_DEV_SESSION_SECRET", secrets.token_hex(32)) + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import pytest # noqa: E402 +from httpx import ASGITransport, AsyncClient # noqa: E402 + + +@pytest.fixture() +def tmp_data_dir(tmp_path, monkeypatch): + monkeypatch.setenv("JUMPHOST_DATA_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture(autouse=True) +def _reset_rate_limiter(): + """login_rate_limiter (app/security/rate_limit.py) ist bewusst ein + Prozess-weiter In-Memory-Singleton (siehe Konzept/Docstring dort) -- + genau das wird hier durch die Tests bestaetigt (test_source_ip_rate_limit_*). + Damit sich einzelne Tests nicht gegenseitig ueber ihr Kontingent + beeinflussen, wird der Zustand vor JEDEM Test zurueckgesetzt.""" + from app.security.rate_limit import login_rate_limiter + + login_rate_limiter._events.clear() + yield + login_rate_limiter._events.clear() + + +@pytest.fixture() +async def client(tmp_data_dir): + # app.config.settings ist ein Modul-Singleton, der beim ERSTEN Import des + # Prozesses einmalig aus der Umgebung gelesen wird (siehe app/config.py) -- + # spaetere Aenderungen von JUMPHOST_DATA_DIR je Test wirken sich darauf + # nicht mehr aus. Fuer Testisolation wird daher die (immer gleiche) + # DB-Datei vor jedem Test explizit zurueckgesetzt statt sich auf einen + # neuen Datenpfad pro Test zu verlassen. + from app.main import app + from app.config import settings + + for suffix in ("", "-wal", "-shm"): + p = settings.data_dir / f"jumphost.db{suffix}" + if p.exists(): + p.unlink() + + transport = ASGITransport(app=app) + # https:// Base-URL, da unsere Session-Cookies "Secure" gesetzt haben + # (Konzept 6.2) und httpx' Cookie-Jar dies wie ein Browser respektiert. + async with AsyncClient(transport=transport, base_url="https://testserver") as ac: + async with app.router.lifespan_context(app): + yield ac diff --git a/tests/test_audit_chain.py b/tests/test_audit_chain.py new file mode 100644 index 0000000..c59729f --- /dev/null +++ b/tests/test_audit_chain.py @@ -0,0 +1,63 @@ +import pytest +import aiosqlite + +from app.security.audit import verify_chain, write_audit_event +from app.db import MIGRATIONS_DIR + + +async def _fresh_db() -> aiosqlite.Connection: + conn = await aiosqlite.connect(":memory:") + for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")): + await conn.executescript(migration_file.read_text(encoding="utf-8")) + return conn + + +@pytest.mark.asyncio +async def test_chain_intact_after_writes(): + conn = await _fresh_db() + for i in range(5): + await write_audit_event( + conn, event_type="test_event", user_id=None, client_ip="127.0.0.1", details={"i": i} + ) + await conn.commit() + + intact, broken_at = await verify_chain(conn) + assert intact is True + assert broken_at is None + await conn.close() + + +@pytest.mark.asyncio +async def test_tamper_detected_after_direct_update(): + conn = await _fresh_db() + for i in range(3): + await write_audit_event( + conn, event_type="test_event", user_id=None, client_ip="127.0.0.1", details={"i": i} + ) + await conn.commit() + + # Der Append-only-Trigger blockt normale UPDATEs -- simuliert wird hier + # eine Umgehung auf DB-Ebene (z.B. Datei-Manipulation waehrend der App + # gestoppt ist), um zu zeigen, dass verify_chain() dies unabhaengig von + # den Triggern erkennt. + await conn.execute("DROP TRIGGER no_audit_update") + await conn.execute("UPDATE audit_log SET details_json = '{\"i\": 999}' WHERE id = 2") + await conn.commit() + + intact, broken_at = await verify_chain(conn) + assert intact is False + assert broken_at == 2 + await conn.close() + + +@pytest.mark.asyncio +async def test_append_only_trigger_blocks_update(): + conn = await _fresh_db() + await write_audit_event( + conn, event_type="test_event", user_id=None, client_ip="127.0.0.1", details={} + ) + await conn.commit() + + with pytest.raises(aiosqlite.Error): + await conn.execute("UPDATE audit_log SET event_type = 'tampered' WHERE id = 1") + await conn.close() diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py new file mode 100644 index 0000000..4a1c71f --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +1,67 @@ +"""End-to-End-Test des Login-Flows (Passwort -> TOTP-Enrollment -> Session) +gegen die echte FastAPI-App mit einer temporaeren SQLite-DB. + +Die `client`-Fixture liegt in conftest.py (von mehreren Testdateien genutzt, +u.a. tests/test_pentest_security.py).""" +import pyotp +import pytest + + +@pytest.mark.asyncio +async def test_full_login_flow_with_totp_enrollment(client, monkeypatch): + from app.db import get_db + from app.security.passwords import hash_password + + conn = get_db() + await conn.execute( + "INSERT INTO users (username, password_hash, must_change_password) VALUES (?, ?, 0)", + ("alice", hash_password("Correct-Horse-Battery-Staple-1")), + ) + await conn.commit() + + resp = await client.post( + "/auth/login", json={"username": "alice", "password": "Correct-Horse-Battery-Staple-1"} + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["totp_enrolled"] is False + pending_token = body["pending_token"] + + resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending_token}) + assert resp.status_code == 200, resp.text + provisioning_uri = resp.json()["provisioning_uri"] + secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"] + + code = pyotp.TOTP(secret).now() + resp = await client.post( + "/auth/totp/enroll/confirm", json={"pending_token": pending_token, "code": code} + ) + assert resp.status_code == 200, resp.text + assert len(resp.json()["recovery_codes"]) == 10 + assert "jh_session" in resp.cookies + + resp = await client.get("/auth/me") + assert resp.status_code == 200, resp.text + assert resp.json()["username"] == "alice" + + +@pytest.mark.asyncio +async def test_wrong_password_rejected(client): + from app.db import get_db + from app.security.passwords import hash_password + + conn = get_db() + await conn.execute( + "INSERT INTO users (username, password_hash, must_change_password) VALUES (?, ?, 0)", + ("bob", hash_password("Correct-Horse-Battery-Staple-2")), + ) + await conn.commit() + + resp = await client.post("/auth/login", json={"username": "bob", "password": "wrong"}) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_unauthenticated_admin_access_denied(client): + resp = await client.get("/admin/users") + assert resp.status_code == 401 diff --git a/tests/test_passwords_totp.py b/tests/test_passwords_totp.py new file mode 100644 index 0000000..3fd452e --- /dev/null +++ b/tests/test_passwords_totp.py @@ -0,0 +1,28 @@ +from app.security.passwords import hash_password, verify_password +from app.security.totp import ( + decrypt_totp_secret, + encrypt_totp_secret, + generate_totp_secret, + verify_totp_code, +) +import pyotp + + +def test_password_hash_roundtrip(): + h = hash_password("Sup3rSecret!Passphrase") + assert verify_password(h, "Sup3rSecret!Passphrase") + assert not verify_password(h, "wrong-password") + + +def test_totp_secret_encryption_roundtrip(): + secret = generate_totp_secret() + enc = encrypt_totp_secret(secret) + assert enc != secret.encode() + assert decrypt_totp_secret(enc) == secret + + +def test_totp_verification(): + secret = generate_totp_secret() + code = pyotp.TOTP(secret).now() + assert verify_totp_code(secret, code) + assert not verify_totp_code(secret, "000000") or pyotp.TOTP(secret).now() == "000000" diff --git a/tests/test_pentest_security.py b/tests/test_pentest_security.py new file mode 100644 index 0000000..cab28c0 --- /dev/null +++ b/tests/test_pentest_security.py @@ -0,0 +1,448 @@ +""" +Dynamischer Sicherheitstest (Mini-Pentest) gegen die laufende FastAPI-App. + +Scope/Methodik: Black-/Grey-Box-Angriffstests auf Anwendungsebene gegen die +Jumphost-Webanwendung in einer isolierten lokalen Testumgebung (kein echtes +Netzwerk, kein echtes Zielsystem). Abgedeckt: Authentifizierungs-Bypass, +Session-/Cookie-Manipulation, RBAC-/IDOR-Bypass, Injection, Rate-Limiting/ +Lockout, Security-Header, Informationslecks (Enumeration/Fehlermeldungen), +Audit-Log-Vollstaendigkeit. NICHT abgedeckt (siehe Pentest-Report): +Netzwerk-/Infrastruktur-Pentest, echter RDP/guacd-Pfad, Social Engineering, +physische Sicherheit. +""" +from __future__ import annotations + +import time + +import pyotp +import pytest +from itsdangerous import URLSafeSerializer + +from app.security.passwords import hash_password + + +async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int: + cursor = await conn.execute( + "INSERT INTO users (username, password_hash, is_admin, must_change_password) " + "VALUES (?, ?, ?, 0)", + (username, hash_password(password), int(is_admin)), + ) + await conn.commit() + return cursor.lastrowid + + +async def _enroll_totp_and_login(client, pending_token: str) -> tuple[str, str]: + """Fuehrt TOTP-Enrollment durch und gibt (secret, session_cookie) zurueck.""" + resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending_token}) + assert resp.status_code == 200, resp.text + provisioning_uri = resp.json()["provisioning_uri"] + secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"] + code = pyotp.TOTP(secret).now() + resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending_token, "code": code}) + assert resp.status_code == 200, resp.text + return secret, resp.cookies.get("jh_session") + + +async def _login_full(client, username: str, password: str) -> str: + """Login + TOTP-Enrollment fuer einen frischen User, gibt Session-Cookie zurueck.""" + resp = await client.post("/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + pending = resp.json()["pending_token"] + _, cookie = await _enroll_totp_and_login(client, pending) + return cookie + + +# --------------------------------------------------------------------------- +# 1) Authentifizierung: Enumeration, Brute-Force, Lockout +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_no_username_enumeration_via_error_message(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "carol", "Correct-Horse-Battery-Staple-3") + + resp_unknown = await client.post("/auth/login", json={"username": "no_such_user_xyz", "password": "whatever12345"}) + resp_wrongpw = await client.post("/auth/login", json={"username": "carol", "password": "wrong-password-123"}) + + assert resp_unknown.status_code == resp_wrongpw.status_code == 401 + assert resp_unknown.json()["detail"] == resp_wrongpw.json()["detail"] + + +@pytest.mark.asyncio +async def test_account_lockout_after_repeated_failures(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "dave", "Correct-Horse-Battery-Staple-4") + + last_status = None + for _ in range(6): + resp = await client.post("/auth/login", json={"username": "dave", "password": "wrong"}) + last_status = resp.status_code + + # Nach >=5 Fehlversuchen muss das Konto gesperrt sein (Konzept 6.2), + # selbst wenn danach das RICHTIGE Passwort verwendet wird. + assert last_status == 423 + resp = await client.post( + "/auth/login", json={"username": "dave", "password": "Correct-Horse-Battery-Staple-4"} + ) + assert resp.status_code == 423 + + +@pytest.mark.asyncio +async def test_source_ip_rate_limit_blocks_excessive_attempts(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "erin", "Correct-Horse-Battery-Staple-5") + + statuses = [] + for i in range(15): + # Unterschiedliche Usernamen, damit NICHT der Account-Lockout (obiger + # Test), sondern ausschliesslich der IP-basierte Rate-Limiter greift. + resp = await client.post( + "/auth/login", json={"username": f"no_such_user_{i}", "password": "x" * 12} + ) + statuses.append(resp.status_code) + + assert 429 in statuses, f"Rate-Limiter hat nicht ausgeloest, Status-Codes: {statuses}" + + +@pytest.mark.asyncio +async def test_sql_injection_payload_in_username_rejected_safely(client): + """Klassische SQLi-Payloads duerfen weder einen Serverfehler (500) noch + einen Auth-Bypass ausloesen -- erwartet wird 401 (falsche Zugangsdaten) + oder 422 (Validierungsfehler durch das Username-Regex).""" + payloads = [ + "admin' OR '1'='1", + "admin'--", + "' OR 1=1;--", + "admin'; DROP TABLE users;--", + "\" OR \"\"=\"", + ] + for payload in payloads: + resp = await client.post("/auth/login", json={"username": payload, "password": "irrelevant123"}) + assert resp.status_code in (401, 422), f"Unerwarteter Status fuer Payload {payload!r}: {resp.status_code}" + + # Datenbank muss danach noch normal funktionieren (kein DROP TABLE griff). + from app.db import get_db + conn = get_db() + cursor = await conn.execute("SELECT COUNT(*) FROM users") + row = await cursor.fetchone() + assert row is not None # wuerde eine sqlite3.OperationalError werfen, waere die Tabelle weg + + +# --------------------------------------------------------------------------- +# 2) Session-/Cookie-Sicherheit +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_session_cookie_security_flags(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "frank", "Correct-Horse-Battery-Staple-6") + resp = await client.post("/auth/login", json={"username": "frank", "password": "Correct-Horse-Battery-Staple-6"}) + pending = resp.json()["pending_token"] + resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending}) + provisioning_uri = resp.json()["provisioning_uri"] + secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"] + code = pyotp.TOTP(secret).now() + + resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code}) + set_cookie = resp.headers.get("set-cookie", "") + assert "HttpOnly" in set_cookie + assert "Secure" in set_cookie + assert "SameSite=strict" in set_cookie or "SameSite=Strict" in set_cookie + + +@pytest.mark.asyncio +async def test_forged_session_cookie_with_wrong_secret_rejected(client): + """Ein Angreifer, der die Cookie-STRUKTUR kennt (z.B. aus diesem + Open-Source-Code) aber NICHT den Session-Secret des Zielsystems, darf + sich damit keinen gueltigen Admin-Zugang faelschen koennen.""" + forged_serializer = URLSafeSerializer("attacker-controlled-wrong-secret", salt="jumphost-session") + forged_token = forged_serializer.dumps({"uid": 1, "sv": 1, "iat": time.time(), "seen": time.time()}) + + client.cookies.set("jh_session", forged_token) + resp = await client.get("/auth/me") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_password_change_invalidates_old_session_everywhere(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "grace", "Correct-Horse-Battery-Staple-7") + old_cookie = await _login_full(client, "grace", "Correct-Horse-Battery-Staple-7") + + resp = await client.get("/auth/me") + assert resp.status_code == 200 + + resp = await client.post( + "/auth/change-password", + json={"current_password": "Correct-Horse-Battery-Staple-7", "new_password": "Even-Str0nger-Passphrase!"}, + ) + assert resp.status_code == 200, resp.text + + # Altes Cookie (vor dem Passwortwechsel ausgestellt) erneut einspielen -- + # muss durch die session_version-Pruefung invalidiert sein (Konzept 6.2). + client.cookies.set("jh_session", old_cookie) + resp = await client.get("/auth/me") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_everywhere_invalidates_session(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "heidi", "Correct-Horse-Battery-Staple-8") + cookie = await _login_full(client, "heidi", "Correct-Horse-Battery-Staple-8") + + resp = await client.post("/auth/logout-everywhere") + assert resp.status_code == 200 + + client.cookies.set("jh_session", cookie) + resp = await client.get("/auth/me") + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# 3) RBAC / IDOR (Insecure Direct Object Reference) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_non_admin_cannot_reach_admin_endpoints(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "ivan", "Correct-Horse-Battery-Staple-9", is_admin=False) + await _login_full(client, "ivan", "Correct-Horse-Battery-Staple-9") + + admin_endpoints = [ + ("GET", "/admin/users"), + ("GET", "/admin/host-groups"), + ("GET", "/admin/hosts"), + ("GET", "/admin/audit-log"), + ("GET", "/admin/audit-log/verify"), + ] + for method, url in admin_endpoints: + resp = await client.request(method, url) + assert resp.status_code == 403, f"{method} {url} sollte 403 liefern, war {resp.status_code}" + + # Auch schreibende Admin-Endpunkte muessen blocken. + resp = await client.post("/admin/host-groups", json={"name": "sollte-nicht-klappen"}) + assert resp.status_code == 403 + resp = await client.post( + "/admin/users", json={"username": "eviladmin", "initial_password": "Whatever123456!", "is_admin": True} + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_user_cannot_access_host_outside_granted_hostgroup(client): + """Kern-RBAC-Test: Nutzer 'judy' bekommt NUR Rechte auf Hostgruppe A; + Zugriffsversuche (Dateitransfer-Endpunkt als Stellvertreter fuer + RBAC-gepruefte Aktionen) auf einen Host in Hostgruppe B muessen 403 + liefern -- unabhaengig davon, dass die Host-ID gueltig/erratbar ist.""" + from app.db import get_db + from app.security.passwords import hash_password + + conn = get_db() + admin_id = await _create_user(conn, "admin_rbac", "Correct-Horse-Battery-Staple-A", is_admin=True) + user_id = await _create_user(conn, "judy", "Correct-Horse-Battery-Staple-J", is_admin=False) + + await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'gruppe-a')") + await conn.execute("INSERT INTO host_groups (id, name) VALUES (2, 'gruppe-b')") + await conn.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "file_transfer_enabled) VALUES (1, 1, 'a01', '10.0.0.1', 'ssh', 22, 'linux', 1)" + ) + await conn.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "file_transfer_enabled) VALUES (2, 2, 'b01', '10.0.0.2', 'ssh', 22, 'linux', 1)" + ) + # judy bekommt file_transfer NUR auf Hostgruppe 1 (role_id 3 = file_transfer, siehe Migration 0001). + await conn.execute( + "INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id, granted_by) VALUES (?, 1, 3, ?)", + (user_id, admin_id), + ) + await conn.commit() + + await _login_full(client, "judy", "Correct-Horse-Battery-Staple-J") + + # Erlaubter Host (Gruppe 1): RBAC-Check muss durchgehen (Fehler danach + # ist erwartet, da kein echtes SSH-Ziel existiert -- wichtig ist der + # Unterschied 403 vs. "kommt ueberhaupt durch die Berechtigungspruefung"). + resp = await client.post( + "/ssh/1/files/upload?remote_path=/tmp/test.txt", + files={"file": ("test.txt", b"hello", "text/plain")}, + ) + assert resp.status_code != 403, "Legitimer Zugriff auf eigene Hostgruppe wurde faelschlich blockiert" + + # Verbotener Host (Gruppe 2, keine Rolle vergeben) -- MUSS 403 sein. + resp = await client.post( + "/ssh/2/files/upload?remote_path=/tmp/test.txt", + files={"file": ("test.txt", b"hello", "text/plain")}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_role_on_one_hostgroup_does_not_grant_different_permission_type(client): + """judy hat NUR ssh_connect auf Gruppe 1, NICHT file_transfer -- der + Filetransfer-Endpunkt muss trotz gueltiger ssh_connect-Rolle blocken + (Rollen sind pro Aktionstyp granular, nicht pauschal 'Zugriff auf Host').""" + from app.db import get_db + + conn = get_db() + admin_id = await _create_user(conn, "admin_rbac2", "Correct-Horse-Battery-Staple-B", is_admin=True) + user_id = await _create_user(conn, "mallory", "Correct-Horse-Battery-Staple-M", is_admin=False) + + await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'gruppe-c')") + await conn.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "file_transfer_enabled) VALUES (1, 1, 'c01', '10.0.0.3', 'ssh', 22, 'linux', 1)" + ) + # role_id 1 = ssh_connect (siehe Migration 0001) -- explizit KEIN file_transfer. + await conn.execute( + "INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id, granted_by) VALUES (?, 1, 1, ?)", + (user_id, admin_id), + ) + await conn.commit() + + await _login_full(client, "mallory", "Correct-Horse-Battery-Staple-M") + + resp = await client.post( + "/ssh/1/files/upload?remote_path=/tmp/test.txt", + files={"file": ("test.txt", b"hello", "text/plain")}, + ) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# 4) Dateitransfer-Haertung +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_oversized_upload_rejected(client, monkeypatch): + from app.db import get_db + import app.ssh_proxy.sftp as sftp_module + + conn = get_db() + admin_id = await _create_user(conn, "admin_upload", "Correct-Horse-Battery-Staple-C", is_admin=True) + await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'gruppe-d')") + await conn.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "file_transfer_enabled) VALUES (1, 1, 'd01', '10.0.0.4', 'ssh', 22, 'linux', 1)" + ) + await conn.commit() + + # Limit fuer den Test drastisch verkleinern statt 200 MiB echt zu senden. + monkeypatch.setattr(sftp_module, "MAX_UPLOAD_BYTES", 10) + await _login_full(client, "admin_upload", "Correct-Horse-Battery-Staple-C") + + resp = await client.post( + "/ssh/1/files/upload?remote_path=/tmp/big.bin", + files={"file": ("big.bin", b"x" * 1000, "application/octet-stream")}, + ) + assert resp.status_code == 413 + + +@pytest.mark.asyncio +async def test_malware_flagged_upload_is_blocked(client, monkeypatch): + from app.db import get_db + import app.ssh_proxy.sftp as sftp_module + + conn = get_db() + await _create_user(conn, "admin_av", "Correct-Horse-Battery-Staple-D", is_admin=True) + await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'gruppe-e')") + await conn.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "file_transfer_enabled) VALUES (1, 1, 'e01', '10.0.0.5', 'ssh', 22, 'linux', 1)" + ) + await conn.commit() + + monkeypatch.setattr(sftp_module, "scan_bytes", lambda data: "infected:EICAR-Test-Signature") + await _login_full(client, "admin_av", "Correct-Horse-Battery-Staple-D") + + resp = await client.post( + "/ssh/1/files/upload?remote_path=/tmp/eicar.txt", + files={"file": ("eicar.txt", b"malware-like-content", "text/plain")}, + ) + assert resp.status_code == 400 + assert "AV-Scan" in resp.json()["detail"] or "blockiert" in resp.json()["detail"] + + # Blockierter Upload darf NICHT als erfolgreicher Filetransfer geloggt sein. + cursor = await conn.execute("SELECT COUNT(*) FROM file_transfers") + row = await cursor.fetchone() + assert row[0] == 0 + + +# --------------------------------------------------------------------------- +# 5) HTTP-Security-Header (auf jeder Antwort, nicht nur auf Login-Seiten) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_security_headers_present_on_every_response(client): + for path in ["/", "/healthz", "/admin/users"]: + resp = await client.get(path) + headers = resp.headers + assert headers.get("x-frame-options") == "DENY" + assert headers.get("x-content-type-options") == "nosniff" + assert "content-security-policy" in headers + assert "script-src 'self'" in headers["content-security-policy"] + assert "strict-transport-security" in headers + assert headers.get("referrer-policy") == "no-referrer" + + +@pytest.mark.asyncio +async def test_no_server_stack_traces_leaked_on_bad_input(client): + resp = await client.post("/auth/login", json={"username": "x", "password": ""}) + assert resp.status_code in (401, 422) + assert "Traceback" not in resp.text + assert "File \"/" not in resp.text + + +# --------------------------------------------------------------------------- +# 6) Audit-Log-Vollstaendigkeit fuer sicherheitsrelevante Ereignisse +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_failed_and_successful_logins_are_audited(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "audituser", "Correct-Horse-Battery-Staple-E") + + await client.post("/auth/login", json={"username": "audituser", "password": "wrong"}) + await _login_full(client, "audituser", "Correct-Horse-Battery-Staple-E") + + cursor = await conn.execute( + "SELECT event_type FROM audit_log WHERE event_type IN " + "('login_failed','login_password_ok','login_success','totp_enroll_confirmed') ORDER BY id" + ) + events = [row[0] for row in await cursor.fetchall()] + assert "login_failed" in events + assert "login_password_ok" in events + assert "totp_enroll_confirmed" in events + + +@pytest.mark.asyncio +async def test_audit_chain_stays_intact_after_full_test_scenario(client): + """Regressionscheck: nach allen obigen Aktionen (viele Logins, + Fehlversuche, Admin-Aktionen) muss die Hash-Chain weiterhin intakt sein -- + ein Bug, der Eintraege ausser der Reihe schreibt, wuerde hier auffallen.""" + from app.db import get_db + from app.security.audit import verify_chain + + conn = get_db() + await _create_user(conn, "chaincheck", "Correct-Horse-Battery-Staple-F") + await _login_full(client, "chaincheck", "Correct-Horse-Battery-Staple-F") + + intact, broken_at = await verify_chain(conn) + assert intact is True, f"Audit-Chain gebrochen bei id={broken_at}" diff --git a/tests/test_rbac.py b/tests/test_rbac.py new file mode 100644 index 0000000..f4c59f9 --- /dev/null +++ b/tests/test_rbac.py @@ -0,0 +1,43 @@ +import pytest +import aiosqlite + +from app.db import MIGRATIONS_DIR +from app.rbac import user_has_role, user_has_role_for_host + + +async def _fresh_db() -> aiosqlite.Connection: + conn = await aiosqlite.connect(":memory:") + for migration_file in sorted(MIGRATIONS_DIR.glob("*.sql")): + await conn.executescript(migration_file.read_text(encoding="utf-8")) + return conn + + +@pytest.mark.asyncio +async def test_rbac_grants_and_expiry(): + conn = await _fresh_db() + await conn.execute("INSERT INTO users (id, username, password_hash) VALUES (1, 'alice', 'x')") + await conn.execute("INSERT INTO host_groups (id, name) VALUES (1, 'linux-prod')") + await conn.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type) " + "VALUES (1, 1, 'db01', '10.0.0.1', 'ssh', 22, 'linux')" + ) + + assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect") + + await conn.execute( + "INSERT INTO user_hostgroup_roles (user_id, host_group_id, role_id) VALUES (1, 1, 1)" + ) + await conn.commit() + + assert await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect") + assert await user_has_role_for_host(conn, user_id=1, host_id=1, role_name="ssh_connect") + assert not await user_has_role_for_host(conn, user_id=1, host_id=1, role_name="rdp_connect") + + # Abgelaufene Freigabe darf nicht mehr gelten. + await conn.execute( + "UPDATE user_hostgroup_roles SET expires_at = '2000-01-01T00:00:00.000000Z' " + "WHERE user_id = 1 AND host_group_id = 1 AND role_id = 1" + ) + await conn.commit() + assert not await user_has_role(conn, user_id=1, host_group_id=1, role_name="ssh_connect") + await conn.close()