From 199f30699340a42e5f2c88554c952405e3cf390e Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Wed, 19 Aug 2026 22:33:19 +0200 Subject: [PATCH] second commit --- .gitignore | 14 + Pentest_Report.md | 164 +++++++ README.md | 156 ++++++ ansible/inventory/group_vars/all.yml | 35 ++ .../group_vars/jumphost_no_nginx.yml.example | 5 + .../jumphost_with_nginx.yml.example | 4 + .../inventory/group_vars/vault.yml.example | 6 + ansible/inventory/production.ini.example | 6 + ansible/roles/backup/handlers/main.yml | 4 + ansible/roles/backup/tasks/main.yml | 47 ++ ansible/roles/backup/templates/backup.sh.j2 | 17 + .../templates/jumphost-backup.service.j2 | 16 + .../backup/templates/jumphost-backup.timer.j2 | 10 + ansible/roles/fail2ban/handlers/main.yml | 5 + ansible/roles/fail2ban/tasks/main.yml | 38 ++ .../roles/firewall_nftables/handlers/main.yml | 5 + .../roles/firewall_nftables/tasks/main.yml | 25 + .../templates/jumphost.nft.j2 | 46 ++ ansible/roles/frontend_assets/tasks/main.yml | 25 + ansible/roles/guacd/handlers/main.yml | 10 + ansible/roles/guacd/tasks/main.yml | 64 +++ .../roles/guacd/templates/guacd.service.j2 | 20 + ansible/roles/jumphost_app/handlers/main.yml | 9 + ansible/roles/jumphost_app/tasks/main.yml | 76 +++ .../templates/jumphost-app.service.j2 | 58 +++ ansible/roles/nginx_proxy/handlers/main.yml | 5 + ansible/roles/nginx_proxy/tasks/main.yml | 45 ++ ansible/roles/nginx_proxy/tasks/snippets.yml | 20 + .../nginx_proxy/templates/jumphost.conf.j2 | 63 +++ .../templates/jumphost_proxy_headers.conf.j2 | 11 + .../roles/os_hardening/CIS_STIG_MAPPING.md | 71 +++ ansible/roles/os_hardening/defaults/main.yml | 47 ++ ansible/roles/os_hardening/handlers/main.yml | 32 ++ ansible/roles/os_hardening/tasks/aide.yml | 86 ++++ ansible/roles/os_hardening/tasks/auditd.yml | 98 ++++ ansible/roles/os_hardening/tasks/banners.yml | 22 + ansible/roles/os_hardening/tasks/cron_at.yml | 36 ++ .../os_hardening/tasks/file_permissions.yml | 38 ++ .../os_hardening/tasks/kernel_modules.yml | 41 ++ ansible/roles/os_hardening/tasks/main.yml | 47 ++ ansible/roles/os_hardening/tasks/mounts.yml | 48 ++ ansible/roles/os_hardening/tasks/packages.yml | 33 ++ .../tasks/pam_password_policy.yml | 83 ++++ .../roles/os_hardening/tasks/rootkit_scan.yml | 24 + ansible/roles/os_hardening/tasks/sshd.yml | 32 ++ ansible/roles/os_hardening/tasks/sysctl.yml | 58 +++ .../roles/python_runtime/handlers/main.yml | 8 + ansible/roles/python_runtime/tasks/main.yml | 93 ++++ ansible/roles/sqlite_init/tasks/main.yml | 25 + ansible/roles/tls_certificates/tasks/main.yml | 85 ++++ ansible/site.yml | 23 + app/__init__.py | 0 app/admin/__init__.py | 0 app/admin/routes.py | 340 +++++++++++++ app/auth/__init__.py | 0 app/auth/deps.py | 122 +++++ app/auth/routes.py | 330 +++++++++++++ app/catalog/__init__.py | 0 app/catalog/routes.py | 67 +++ app/config.py | 75 +++ app/db.py | 69 +++ app/db/migrations/0001_initial.sql | 144 ++++++ app/db/migrations/0002_session_version.sql | 5 + app/db/migrations/0003_rdp_credentials.sql | 7 + app/main.py | 99 ++++ app/models/__init__.py | 0 app/models/schemas.py | 109 +++++ app/rbac.py | 33 ++ app/rdp_proxy/__init__.py | 0 app/rdp_proxy/guacd_client.py | 166 +++++++ app/rdp_proxy/ws_tunnel.py | 170 +++++++ app/recordings/__init__.py | 0 app/recordings/recorder.py | 63 +++ app/security/__init__.py | 0 app/security/audit.py | 70 +++ app/security/av_scan.py | 38 ++ app/security/crypto.py | 32 ++ app/security/passwords.py | 28 ++ app/security/pending_totp.py | 29 ++ app/security/rate_limit.py | 34 ++ app/security/sessions.py | 70 +++ app/security/totp.py | 52 ++ app/ssh_proxy/__init__.py | 0 app/ssh_proxy/proxy.py | 130 +++++ app/ssh_proxy/sftp.py | 145 ++++++ app/ssh_proxy/terminal_ws.py | 144 ++++++ jumphost.zip | Bin 298315 -> 15608 bytes pytest.ini | 3 + requirements-dev.txt | 6 + requirements.txt | 17 + scripts/create_admin.py | 56 +++ scripts/fetch_frontend_assets.sh | 40 ++ static/css/app.css | 90 ++++ static/js/dashboard.js | 74 +++ static/js/login.js | 85 ++++ static/js/rdp.js | 80 ++++ static/js/terminal.js | 100 ++++ templates/dashboard.html | 21 + templates/login.html | 46 ++ templates/rdp.html | 23 + templates/terminal.html | 27 ++ tests/conftest.py | 57 +++ tests/test_audit_chain.py | 63 +++ tests/test_auth_flow.py | 67 +++ tests/test_passwords_totp.py | 28 ++ tests/test_pentest_security.py | 448 ++++++++++++++++++ tests/test_rbac.py | 43 ++ 107 files changed, 5984 insertions(+) create mode 100644 .gitignore create mode 100644 Pentest_Report.md create mode 100644 README.md create mode 100644 ansible/inventory/group_vars/all.yml create mode 100644 ansible/inventory/group_vars/jumphost_no_nginx.yml.example create mode 100644 ansible/inventory/group_vars/jumphost_with_nginx.yml.example create mode 100644 ansible/inventory/group_vars/vault.yml.example create mode 100644 ansible/inventory/production.ini.example create mode 100644 ansible/roles/backup/handlers/main.yml create mode 100644 ansible/roles/backup/tasks/main.yml create mode 100644 ansible/roles/backup/templates/backup.sh.j2 create mode 100644 ansible/roles/backup/templates/jumphost-backup.service.j2 create mode 100644 ansible/roles/backup/templates/jumphost-backup.timer.j2 create mode 100644 ansible/roles/fail2ban/handlers/main.yml create mode 100644 ansible/roles/fail2ban/tasks/main.yml create mode 100644 ansible/roles/firewall_nftables/handlers/main.yml create mode 100644 ansible/roles/firewall_nftables/tasks/main.yml create mode 100644 ansible/roles/firewall_nftables/templates/jumphost.nft.j2 create mode 100644 ansible/roles/frontend_assets/tasks/main.yml create mode 100644 ansible/roles/guacd/handlers/main.yml create mode 100644 ansible/roles/guacd/tasks/main.yml create mode 100644 ansible/roles/guacd/templates/guacd.service.j2 create mode 100644 ansible/roles/jumphost_app/handlers/main.yml create mode 100644 ansible/roles/jumphost_app/tasks/main.yml create mode 100644 ansible/roles/jumphost_app/templates/jumphost-app.service.j2 create mode 100644 ansible/roles/nginx_proxy/handlers/main.yml create mode 100644 ansible/roles/nginx_proxy/tasks/main.yml create mode 100644 ansible/roles/nginx_proxy/tasks/snippets.yml create mode 100644 ansible/roles/nginx_proxy/templates/jumphost.conf.j2 create mode 100644 ansible/roles/nginx_proxy/templates/jumphost_proxy_headers.conf.j2 create mode 100644 ansible/roles/os_hardening/CIS_STIG_MAPPING.md create mode 100644 ansible/roles/os_hardening/defaults/main.yml create mode 100644 ansible/roles/os_hardening/handlers/main.yml create mode 100644 ansible/roles/os_hardening/tasks/aide.yml create mode 100644 ansible/roles/os_hardening/tasks/auditd.yml create mode 100644 ansible/roles/os_hardening/tasks/banners.yml create mode 100644 ansible/roles/os_hardening/tasks/cron_at.yml create mode 100644 ansible/roles/os_hardening/tasks/file_permissions.yml create mode 100644 ansible/roles/os_hardening/tasks/kernel_modules.yml create mode 100644 ansible/roles/os_hardening/tasks/main.yml create mode 100644 ansible/roles/os_hardening/tasks/mounts.yml create mode 100644 ansible/roles/os_hardening/tasks/packages.yml create mode 100644 ansible/roles/os_hardening/tasks/pam_password_policy.yml create mode 100644 ansible/roles/os_hardening/tasks/rootkit_scan.yml create mode 100644 ansible/roles/os_hardening/tasks/sshd.yml create mode 100644 ansible/roles/os_hardening/tasks/sysctl.yml create mode 100644 ansible/roles/python_runtime/handlers/main.yml create mode 100644 ansible/roles/python_runtime/tasks/main.yml create mode 100644 ansible/roles/sqlite_init/tasks/main.yml create mode 100644 ansible/roles/tls_certificates/tasks/main.yml create mode 100644 ansible/site.yml create mode 100644 app/__init__.py create mode 100644 app/admin/__init__.py create mode 100644 app/admin/routes.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/deps.py create mode 100644 app/auth/routes.py create mode 100644 app/catalog/__init__.py create mode 100644 app/catalog/routes.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/db/migrations/0001_initial.sql create mode 100644 app/db/migrations/0002_session_version.sql create mode 100644 app/db/migrations/0003_rdp_credentials.sql create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/schemas.py create mode 100644 app/rbac.py create mode 100644 app/rdp_proxy/__init__.py create mode 100644 app/rdp_proxy/guacd_client.py create mode 100644 app/rdp_proxy/ws_tunnel.py create mode 100644 app/recordings/__init__.py create mode 100644 app/recordings/recorder.py create mode 100644 app/security/__init__.py create mode 100644 app/security/audit.py create mode 100644 app/security/av_scan.py create mode 100644 app/security/crypto.py create mode 100644 app/security/passwords.py create mode 100644 app/security/pending_totp.py create mode 100644 app/security/rate_limit.py create mode 100644 app/security/sessions.py create mode 100644 app/security/totp.py create mode 100644 app/ssh_proxy/__init__.py create mode 100644 app/ssh_proxy/proxy.py create mode 100644 app/ssh_proxy/sftp.py create mode 100644 app/ssh_proxy/terminal_ws.py create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 scripts/create_admin.py create mode 100644 scripts/fetch_frontend_assets.sh create mode 100644 static/css/app.css create mode 100644 static/js/dashboard.js create mode 100644 static/js/login.js create mode 100644 static/js/rdp.js create mode 100644 static/js/terminal.js create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/rdp.html create mode 100644 templates/terminal.html create mode 100644 tests/conftest.py create mode 100644 tests/test_audit_chain.py create mode 100644 tests/test_auth_flow.py create mode 100644 tests/test_passwords_totp.py create mode 100644 tests/test_pentest_security.py create mode 100644 tests/test_rbac.py 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 05375520472cdaba25a4b6de4ac97069e6327565..4c8336daac871d5aa3d755705e2f792177bf2e50 100644 GIT binary patch literal 15608 zcmbtbONbm*6s<`zG5<_5zx)}JAwQFuWF{nH@JCPtLkx)qjfrhdch5}q^mKQstMfrY zRN6>{{2;4A5oX~=(T%tY7zDp4Y8Dz0!G$71LL|#)0s9_f2oOjQ; z_tmR9mBY`>owvx5fA(JOKH2ys`mxYi=M-wwmC1577=He_h0ffk@6`(j#Z^1I*y)%j z|5@Y5;U>gemodar2;o*LAx!VxdSRouv@rdbFibCn$-04CEKh`RC-2k?^3uXxC9CSE zaMkK$rs9{+o(lo@h_yY!t*?))ubTq8xoNKyf}Id6lb3dNg9L1)0#*wqL##mHe9mD} z;Z{*BKc6l8Ij=NPwbYK5+Cvei%ej0pLNnI-(+k+xE{c{L3)l6~#d<+rTGc*z5hJZ_ zgEZ|;_-^2pOVRRfU8)yuUSU9w5-a*BXf>a$`Cbt9$q&Mczd1Wb2%ge-ey#!=?~(vx z{blv52BO%krG9@A{gAH_Qnnfi_vqz%;hMO#Zp=T9-9Q5op%o&q*Gym=3D_FwLJRD4 zzLYEbVY#0QOnGS+xZ@?fz!eBH>wA?TTHfr{dO==Vs1C9`RBMo*t`yxMAAy}R)q0+6 zP0QiB1WdfD^>+Wvva8tVlhk&ICuXGm9u>@z; zc$=uP%?>Sk}sCx|!9))K-3;|EN0f1H4~Jz|9?mJl49z1D>P3c+V?kI~}s zVR+C)Kn*q)kkZsay{Ykgk4*EWg>k@(M4s)I=)mIrCWQ)wgV}UZ1=#6*CK`{>4B3`~ z4c}|xp@!R*lhF5xEBN7$@x5HlOuBw9UlOlK!kNDDU%l{^xU`Q;6@t$Aj?jVcHwFG5 z9LEvD!3c#ou4vDZclDh|`OtrGBj8)c##Hf3Os6H{!}dmI$2nQYz< zym3#wKej%&)ri;3P$&$*^Fvn*5A0Md=2=SN!QLkpjX6mvR7qz&O<5^>VS~OG#@y^w zEs7(Ia$O)Dz}!C~T;O|P^8F$~XO2}7I(GZjqy)@}#LLHXZe*Yyn@dRPEVJKM&IOa@ zQpT^90&hBclG}P}$|&enS2lA9=;WtuqWOl|x$#PK_!d+yMv|J_86y8C)`uG=1xJAmjxK z*$OcQblU*4wOTd8gwVGrGz>HaxC}Izb<>Qm8?AeL2T)c@5r>-sxNWf6TD2N&!d0J9 zfWx3u;L4zrSuhoP6e_X053lJqW#b4w0i7fSUE7bJ>NEvlO`zh6+@Cy z{b)xAj*x;;2D=QS8tX#FJqxUqBqJ$9UWSsIw*#3sV@C-Nm1Y+y;}*o&u~1ti&1M@z zX6Q{d`h|*)*^_~`kwI_*MR%e4zO|>stz#&Zs=6Doj*7E<^Bxv&SK$y+RZ06Kq{YR) zZ%>=5h47BEV6nDX7>U}nj~W}p_Q~53f!2{Ba)Q>u!}yIIU8=U(iP~mpiItM+S_T(+ z&5klvyKF;6-q$m>3!TjyoEka9rg?d(R#TM@w2lm*nbuL`XGr>W5>nhiG9OOBJBqX& zU6!O+@cK)rY#^o^-UuT8API4*W?GMUmn_#790%6s22$f{=w;SK0h&gJ*Nk~;bPb8V zFcINKkwG@YrN-EhYoN=Z8`)!IsLhbk=Gcr(dc#DPsv!mu*PnW>_=X&X-R8bfqi!hS zV=J^0A|}utGWcdxRAX<*H*3j@Rw{e1zK{->L8k0k&ecf6?5fkVc8JaVEYGe-iW-R$|&&U9sp``u|p#c;x zvUNvaTq5on!?{@4yl0meA>69LHRuGLCOs>9tP wKNw(0sekz0*>~2Yq-zrHX2WQ__pGxm)uMYkMXumDhs56}2OQ@;0qr>d1DxD|2LJ#7 literal 298315 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%0AG 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 z + + + + 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()

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 Q3;#76iFZxPx# 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 + + + +