fix(security, core): Auth-Pflicht für Ingest-APIs, 500er-Ursachen beheben, Agenten-Workflow
Sicherheit
- install_db.php war ohne Authentifizierung erreichbar und setzte bei jedem
Aufruf das Admin-Passwort auf einen fest im Code stehenden Wert zurück.
Jetzt Auth-Pflicht; ein Konto wird nur bei leerer Benutzertabelle angelegt.
- Stored XSS im Bugtracker-Detail-Modal: Titel, Beschreibung, Fehlermeldung,
Stacktrace und Kommentare gingen ungefiltert durch innerHTML.
- report.php, projects.php und das Veröffentlichen von Releases verlangen jetzt
zwingend ein Token. Publish war zuvor völlig ungeschützt.
- CSRF-Token in allen Formularen, Session-Regenerierung nach Login,
Drosselung fehlgeschlagener Anmeldeversuche.
- Zugangsdaten aus der Versionskontrolle entfernt (Serverdaten.txt,
config.php, .htpasswd, deploy_config.json). Historie enthält sie weiterhin,
Rotation erforderlich (siehe docs/UPGRADE.md).
- Token-Validierung nur noch über SHA-256-Hash; expires_at wird ausgewertet.
Behobene 500er
- Audit::log() war in index.php weder eingebunden noch importiert. Jeder
Klick auf "Aktivierung freigeben" endete in einem Fatal Error.
- Derselbe benannte PDO-Platzhalter mehrfach je Statement (:id in
revokeToken/deleteToken, :q siebenfach in der Volltextsuche). Bei
EMULATE_PREPARES=false ist das nicht zulässig und warf HY093.
- Migration 005 nutzte dynamisches SQL, dessen Semikolons in String-Literalen
vom alten explode(';')-Installer als Statement-Ende gelesen wurden. Sie
schlug still fehl, wodurch push_id/target_agent/tags dauerhaft fehlten.
- Monitor-Umbenennung ohne Transaktion, verschachtelte Transaktionen im
RateLimiter.
Funktionale Korrekturen
- Der Watchdog-Evaluator fehlte vollständig: Monitor-Zustände änderten sich nur
beim Eintreffen eines Heartbeats, ein ausgefallenes System blieb dauerhaft
"up". Erster Lauf auf dem Produktivsystem: 7 von 10 Monitoren waren
tatsächlich seit über einem Tag nicht erreichbar.
- Das Feld "os" fehlte im Monitor-Dialog, wurde aber gespeichert und löschte
damit bei jedem Speichern das Betriebssystem.
- Der Resolve-Dialog existierte im HTML nicht; der Button war funktionslos.
- Versionsvergleich erfolgte lexikografisch, wodurch 1.9.0 als neuer galt
als 1.10.0.
- Schreiboperationen meldeten Erfolg auch für nicht existierende IDs.
- Post/Redirect/Get gegen doppelte Einträge beim Neuladen.
Neue Struktur
- src/bootstrap.php mit PSR-4-Autoloader ersetzt die require-Ketten.
- Core: Config, Http, Csrf, ApiAuth, Logger, Migrator, ErrorReporter.
- Migrator mit zeichenweisem SQL-Parser, dc_migrations und Baseline-Verfahren,
damit bestehende Installationen keine Beispieldaten zurückbekommen.
Agenten-Workflow
- Claim/Lease: Items werden exklusiv übernommen, damit nicht zwei Agenten am
selben Problem arbeiten. action=next holt und reserviert in einem Zug.
- Idempotenz über client_ref, Deduplizierung auch für Feature Requests,
Erkennung von Regressionen, automatische Eskalation des Schweregrads.
- Strukturierter Code-Kontext (repo_url, commit_sha, file_path, line_no).
- Delta-Abfragen über updated_since, Pagination, Bulk-Update.
- Beim Veröffentlichen eines Releases schließen sich Items mit passendem
resolved_in_build selbst.
- Ausgehende Webhooks mit HMAC-Signatur, /api/health, /api/openapi.json.
- Unbehandelte Fehler meldet die Plattform in ihren eigenen Bugtracker.
WebUI
- Serverseitige Filterung mit Pagination statt Rendern aller Datensätze.
- Migrations-Schranke, Evaluator-Warnung, Übersicht aktiver Agenten.
Zeitstempel liegen in der Datenbank durchgängig in UTC und werden für die
Anzeige in die App-Zeitzone umgerechnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a21536f495
commit
e7fbc85db4
+99
-34
@@ -19,7 +19,12 @@ IGNORE_PATTERNS = {
|
||||
'Serverdaten.txt',
|
||||
'Serverdaten.txt.bak',
|
||||
'bin',
|
||||
'obj'
|
||||
'obj',
|
||||
# Laufzeitdaten gehoeren dem Server, nicht dem Arbeitsplatz. var/.htaccess
|
||||
# wird dennoch uebertragen, damit das Verzeichnis existiert und gesperrt ist.
|
||||
'log',
|
||||
'__pycache__',
|
||||
'client-dotnet',
|
||||
}
|
||||
|
||||
def load_config():
|
||||
@@ -76,6 +81,70 @@ def should_ignore(rel_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
def collect_changes(cache):
|
||||
"""Ermittelt alle Dateien, die sich seit dem letzten Deployment geaendert haben."""
|
||||
files_to_upload = []
|
||||
for root, dirs, files in os.walk(PROJECT_DIR):
|
||||
rel_dir = os.path.relpath(root, PROJECT_DIR)
|
||||
if rel_dir == '.':
|
||||
rel_dir = ''
|
||||
|
||||
dirs[:] = [d for d in dirs if not should_ignore(os.path.join(rel_dir, d))]
|
||||
|
||||
for f in files:
|
||||
rel_file = os.path.normpath(os.path.join(rel_dir, f)).replace('\\', '/')
|
||||
if should_ignore(rel_file):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(root, f)
|
||||
file_hash = compute_hash(full_path)
|
||||
|
||||
if cache.get(rel_file) != file_hash:
|
||||
files_to_upload.append((rel_file, full_path, file_hash))
|
||||
|
||||
files_to_upload.sort(key=upload_priority)
|
||||
return files_to_upload
|
||||
|
||||
|
||||
def upload_priority(entry):
|
||||
"""
|
||||
Reihenfolge des Uploads.
|
||||
|
||||
Abhaengigkeiten zuerst: waere public/index.php schon oben, waehrend
|
||||
src/bootstrap.php noch fehlt, liefe die Seite in der Zwischenzeit in einen
|
||||
Fehler. Die .htaccess im Wurzelverzeichnis kommt zuletzt, damit neue Routen
|
||||
erst greifen, wenn ihre Zielskripte vorhanden sind.
|
||||
"""
|
||||
rel_file = entry[0]
|
||||
|
||||
if rel_file == '.htaccess':
|
||||
rank = 4
|
||||
elif rel_file.startswith('public/'):
|
||||
rank = 3
|
||||
elif rel_file.startswith(('src/', 'config/', 'sql/', 'var/')):
|
||||
rank = 1
|
||||
else:
|
||||
rank = 2
|
||||
|
||||
return (rank, rel_file)
|
||||
|
||||
|
||||
def dry_run():
|
||||
"""Zeigt an, was uebertragen wuerde, ohne eine Verbindung aufzubauen."""
|
||||
cache = load_cache()
|
||||
changes = collect_changes(cache)
|
||||
|
||||
if not changes:
|
||||
print("Keine Aenderungen - der Server ist auf dem aktuellen Stand.")
|
||||
return
|
||||
|
||||
total = sum(os.path.getsize(p) for _, p, _ in changes)
|
||||
print(f"{len(changes)} Datei(en) wuerden uebertragen ({total / 1024:.1f} KB):\n")
|
||||
for rel_file, full_path, _ in changes:
|
||||
marker = 'NEU' if rel_file not in cache else ' '
|
||||
print(f" {marker} {rel_file} ({os.path.getsize(full_path) / 1024:.1f} KB)")
|
||||
|
||||
|
||||
def deploy():
|
||||
config = load_config()
|
||||
cache = load_cache()
|
||||
@@ -97,24 +166,7 @@ def deploy():
|
||||
print(f"FTP Connection failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
files_to_upload = []
|
||||
for root, dirs, files in os.walk(PROJECT_DIR):
|
||||
rel_dir = os.path.relpath(root, PROJECT_DIR)
|
||||
if rel_dir == '.':
|
||||
rel_dir = ''
|
||||
|
||||
dirs[:] = [d for d in dirs if not should_ignore(os.path.join(rel_dir, d))]
|
||||
|
||||
for f in files:
|
||||
rel_file = os.path.normpath(os.path.join(rel_dir, f)).replace('\\', '/')
|
||||
if should_ignore(rel_file):
|
||||
continue
|
||||
|
||||
full_path = os.path.join(root, f)
|
||||
file_hash = compute_hash(full_path)
|
||||
|
||||
if cache.get(rel_file) != file_hash:
|
||||
files_to_upload.append((rel_file, full_path, file_hash))
|
||||
files_to_upload = collect_changes(cache)
|
||||
|
||||
if not files_to_upload:
|
||||
print("No changed files to upload. Remote is up to date!")
|
||||
@@ -125,21 +177,34 @@ def deploy():
|
||||
for rel_file, full_path, file_hash in files_to_upload:
|
||||
print(f" -> {rel_file}")
|
||||
|
||||
for rel_file, full_path, file_hash in files_to_upload:
|
||||
remote_file_path = f"/{rel_file}"
|
||||
remote_dir = os.path.dirname(remote_file_path).replace('\\', '/')
|
||||
if remote_dir and remote_dir != '/':
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
|
||||
ftp.cwd('/')
|
||||
print(f"Uploading {rel_file} ...")
|
||||
with open(full_path, 'rb') as f:
|
||||
ftp.storbinary(f"STOR {remote_file_path}", f)
|
||||
new_cache[rel_file] = file_hash
|
||||
uploaded = 0
|
||||
try:
|
||||
for rel_file, full_path, file_hash in files_to_upload:
|
||||
remote_file_path = f"/{rel_file}"
|
||||
remote_dir = os.path.dirname(remote_file_path).replace('\\', '/')
|
||||
if remote_dir and remote_dir != '/':
|
||||
ensure_remote_dir(ftp, remote_dir)
|
||||
|
||||
save_cache(new_cache)
|
||||
ftp.quit()
|
||||
print("Deployment completed successfully!")
|
||||
ftp.cwd('/')
|
||||
print(f"Uploading {rel_file} ...")
|
||||
with open(full_path, 'rb') as f:
|
||||
ftp.storbinary(f"STOR {remote_file_path}", f)
|
||||
|
||||
new_cache[rel_file] = file_hash
|
||||
uploaded += 1
|
||||
finally:
|
||||
# Der Cache wird auch bei einem Abbruch geschrieben. Sonst beginnt der
|
||||
# naechste Lauf wieder bei null und ueberträgt alles erneut.
|
||||
save_cache(new_cache)
|
||||
try:
|
||||
ftp.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"\nDeployment abgeschlossen: {uploaded} von {len(files_to_upload)} Datei(en) uebertragen.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
deploy()
|
||||
if '--dry-run' in sys.argv or '-n' in sys.argv:
|
||||
dry_run()
|
||||
else:
|
||||
deploy()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"_comment": "Kopie als deploy_config.json anlegen und ausfuellen. deploy_config.json ist per .gitignore ausgeschlossen.",
|
||||
"host": "ftp.example.com",
|
||||
"port": 21,
|
||||
"user": "ftp-user",
|
||||
"pass": "ftp-password",
|
||||
"secure": true
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"host": "www531.your-server.de",
|
||||
"port": 21,
|
||||
"user": "bergisnu_4",
|
||||
"pass": "o2#M*NN^5EsT",
|
||||
"secure": true
|
||||
}
|
||||
Reference in New Issue
Block a user