syncova-backup/packages/reports/governance_reports.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

431 lines
16 KiB
Go

package reports
import (
"context"
"errors"
"fmt"
"time"
)
// ErrSecurityInspectorMissing meldet einen Bericht ohne Pruefwerk.
//
// Er wird geworfen statt einen Bericht mit leeren Abschnitten auszuliefern: Ein
// Sicherheitsbericht ohne Pruefung saehe aus wie eine Anlage ohne Befunde.
var ErrSecurityInspectorMissing = errors.New("fuer diesen bericht ist keine sicherheitspruefung eingerichtet")
// buildSecurityReport gibt die Sicherheitsbewertung als Bericht aus.
//
// Er rechnet nicht selbst, sondern nutzt das Security Center (Phase 15). Eine
// zweite Berechnung derselben Zahl liefe frueher oder spaeter auseinander — und
// niemand koennte sagen, welche der beiden stimmt.
func (generator *Generator) buildSecurityReport(buildContext context.Context, report *Report) error {
if generator.securityInspector == nil {
return ErrSecurityInspectorMissing
}
assessment, inspectError := generator.securityInspector.Inspect(buildContext)
if inspectError != nil {
return fmt.Errorf("die sicherheitslage konnte nicht beurteilt werden: %w", inspectError)
}
report.Sections = append(report.Sections, Section{
Title: "Bewertung",
Description: assessment.Summary,
Metrics: []Metric{
KnownMetric("Sicherheitsbewertung", float64(assessment.Score), UnitPercent),
KnownMetric("Erreichbare Punkte der geprüften Bereiche",
float64(assessment.MaximumScore), UnitNone),
TextMetric("Einstufung", assessment.Grade),
KnownMetric("Kritische Befunde", float64(assessment.CriticalFindingCount), UnitCount),
KnownMetric("Ernste Befunde", float64(assessment.HighFindingCount), UnitCount),
KnownMetric("Nicht geprüfte Bereiche", float64(assessment.UncheckedAreaCount), UnitCount),
},
})
areaTable := Table{
Title: "Prüfbereiche",
Columns: []string{"Bereich", "Geprüft", "Punkte", "Gewicht", "Befunde", "Zusammenfassung"},
Rows: make([][]string, 0, len(assessment.Areas)),
}
for _, areaResult := range assessment.Areas {
checkedLabel := "ja"
summaryText := areaResult.Summary
pointsText := fmt.Sprintf("%d", areaResult.EarnedPoints)
if !areaResult.Available {
checkedLabel = "NEIN"
summaryText = areaResult.UnavailableReason
// Ein ungeprueft Bereich bekommt keine Punktzahl — weder null noch
// die volle. Beides waere eine Behauptung ueber etwas, das niemand
// nachgesehen hat.
pointsText = "—"
}
areaTable.Rows = append(areaTable.Rows, []string{
areaResult.Title,
checkedLabel,
pointsText,
fmt.Sprintf("%d", areaResult.Weight),
fmt.Sprintf("%d", len(areaResult.Findings)),
summaryText,
})
}
report.Sections = append(report.Sections, Section{Title: "Bereiche", Tables: []Table{areaTable}})
findingTable := Table{
Title: "Befunde",
Columns: []string{"Schweregrad", "Bereich", "Befund", "Betroffen", "Warum", "Was tun"},
Rows: make([][]string, 0, 16),
EmptyNotice: "Es liegen keine Befunde vor.",
}
for _, finding := range assessment.AllFindings() {
findingTable.Rows = append(findingTable.Rows, []string{
string(finding.Severity),
finding.Area,
finding.Title,
finding.AffectedObject,
finding.Explanation,
finding.Recommendation,
})
}
report.Sections = append(report.Sections, Section{
Title: "Befunde",
Description: "Nach Schweregrad geordnet: Oben steht, was zuerst zu tun ist.",
Tables: []Table{findingTable},
})
if assessment.CriticalFindingCount > 0 {
report.AddNote("Ein kritischer Befund deckelt die Einstufung unabhängig von der " +
"Prozentzahl. Eine Anlage, bei der ein gestohlenes Konto alles vernichten kann, " +
"ist nicht „gut abgesichert mit kleinem Mangel“.")
}
if assessment.UncheckedAreaCount > 0 {
report.AddNote("%d Bereich(e) konnten nicht geprüft werden: %v. Sie gehen weder positiv "+
"noch negativ in die Bewertung ein — deshalb steht neben der Prozentzahl die "+
"erreichbare Punktzahl der tatsächlich geprüften Bereiche.",
assessment.UncheckedAreaCount, assessment.UncheckedAreas)
}
return nil
}
// buildComplianceReport liefert technische Angaben fuer Pruefungen.
//
// PROMPT.md §73 ist an dieser Stelle ausdruecklich: „Syncova soll keine
// Compliance-Zertifizierung vortaeuschen." Der Bericht liefert deshalb Messwerte
// und keine Urteile — kein „erfuellt", kein Haken, keine Norm. Was daraus folgt,
// entscheidet der Pruefer.
func (generator *Generator) buildComplianceReport(buildContext context.Context, report *Report) error {
const measurementStatement = `
SELECT
(SELECT count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL),
(SELECT count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL
AND encrypted_bytes > 0),
(SELECT count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL
AND (legal_hold = true OR (immutable_until IS NOT NULL AND immutable_until > now()))),
(SELECT count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL
AND last_verified_at IS NOT NULL),
(SELECT count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL
AND last_restore_test_at IS NOT NULL),
(SELECT count(*) FROM users WHERE deleted_at IS NULL AND status = 'active'),
(SELECT count(DISTINCT m.user_id) FROM user_mfa_methods m
JOIN users u ON u.id = m.user_id
WHERE m.enabled = true AND m.confirmed_at IS NOT NULL
AND u.deleted_at IS NULL AND u.status = 'active'),
(SELECT count(*) FROM audit_events),
(SELECT count(*) FROM repositories WHERE hardened = true),
(SELECT count(*) FROM repositories)`
var (
totalBackups, encryptedBackups int64
protectedBackups, verifiedBackups int64
restoreTestedBackups, activeUsers int64
usersWithMFA, auditEventCount int64
hardenedRepositories, allRepositories int64
)
scanError := generator.connectionPool.QueryRow(buildContext, measurementStatement).Scan(
&totalBackups, &encryptedBackups, &protectedBackups, &verifiedBackups,
&restoreTestedBackups, &activeUsers, &usersWithMFA, &auditEventCount,
&hardenedRepositories, &allRepositories)
if scanError != nil {
return fmt.Errorf("die kennzahlen fuer die pruefung konnten nicht gelesen werden: %w", scanError)
}
report.Sections = append(report.Sections, Section{
Title: "Technische Angaben",
Description: "Messwerte, keine Urteile. Ob sie eine Anforderung erfüllen, entscheidet " +
"die jeweilige Vorgabe — nicht diese Anlage.",
Metrics: []Metric{
shareMetric("Verschlüsselte Wiederherstellungspunkte", encryptedBackups, totalBackups,
"Es gibt keinen Wiederherstellungspunkt."),
shareMetric("Wiederherstellungspunkte mit Löschschutz", protectedBackups, totalBackups,
"Es gibt keinen Wiederherstellungspunkt."),
shareMetric("Geprüfte Wiederherstellungspunkte", verifiedBackups, totalBackups,
"Es gibt keinen Wiederherstellungspunkt."),
shareMetric("Durch Wiederherstellungstest belegt", restoreTestedBackups, totalBackups,
"Es gibt keinen Wiederherstellungspunkt."),
shareMetric("Konten mit zweitem Faktor", usersWithMFA, activeUsers,
"Es gibt kein aktives Konto."),
shareMetric("Repositories mit Löschschutz", hardenedRepositories, allRepositories,
"Es ist kein Repository eingerichtet."),
TextMetric("Protokollierung sicherheitsrelevanter Handlungen",
auditProtectionLabel(auditEventCount)),
UnknownMetric("Kopie an einem zweiten Ort", UnitPercent,
"Backup Copy ist nicht umgesetzt. Es gibt keine zweite Kopie, deren Anteil "+
"sich messen ließe — die Zahl fehlt, statt als null oder als hundert zu erscheinen."),
},
})
report.Sections = append(report.Sections, Section{
Title: "Grundzahlen",
Metrics: []Metric{
KnownMetric("Wiederherstellungspunkte insgesamt", float64(totalBackups), UnitCount),
KnownMetric("Aktive Konten", float64(activeUsers), UnitCount),
KnownMetric("Repositories", float64(allRepositories), UnitCount),
KnownMetric("Protokollierte Handlungen", float64(auditEventCount), UnitCount),
},
})
report.AddNote("Dieser Bericht ist keine Zertifizierung und ersetzt keine. Er liefert " +
"technische Messwerte, die bei einer Prüfung verlangt werden; die Bewertung dieser " +
"Werte gegen eine Norm leistet er nicht.")
report.AddNote("Das Protokoll sicherheitsrelevanter Handlungen ist durch einen " +
"Datenbank-Trigger gegen nachträgliche Änderung und Löschung gesichert, nicht nur " +
"durch die Anwendung.")
if restoreTestedBackups < totalBackups {
report.AddNote("Nur ein Wiederherstellungstest belegt die Wiederherstellbarkeit. " +
"Manifest- und Blockprüfung sind Indizien; sie zeigen, dass die Daten unversehrt " +
"sind, nicht dass sie sich zurückschreiben lassen.")
}
return nil
}
// shareMetric bildet einen Anteil und benennt einen leeren Nenner.
//
// Null von null ist nicht null Prozent, sondern gar nichts. Der Unterschied
// entscheidet, ob „0 % verschluesselt" einen Missstand meldet oder nur, dass es
// nichts zu verschluesseln gab.
func shareMetric(label string, matchingCount, totalCount int64, emptyReason string) Metric {
if totalCount == 0 {
return UnknownMetric(label, UnitPercent, emptyReason)
}
measuredShare := KnownMetric(label, float64(matchingCount)*100/float64(totalCount), UnitPercent)
measuredShare.Text = fmt.Sprintf("%s (%d von %d)",
FormatPercent(float64(matchingCount)*100/float64(totalCount)), matchingCount, totalCount)
return measuredShare
}
// auditProtectionLabel beschreibt den Zustand der Protokollierung.
func auditProtectionLabel(auditEventCount int64) string {
if auditEventCount == 0 {
return "eingerichtet, bislang ohne Eintrag"
}
return "aktiv und gegen Änderung gesichert"
}
// buildObjectivesReport vergleicht RPO- und RTO-Vorgaben mit der Wirklichkeit.
//
// **RPO laesst sich messen, RTO nicht.** Der Abstand zur letzten erfolgreichen
// Sicherung steht in der Datenbank; die Zeit, die eine Wiederherstellung
// braeuchte, kennt niemand, bevor sie stattgefunden hat. Deshalb steht in der
// RTO-Spalte eine Messung oder ein ausdrueckliches „nicht gemessen" — nie eine
// Hochrechnung aus Datenmenge und Durchsatz. Eine solche Schaetzung waere die
// bequemste Zahl des ganzen Berichts und die einzige, auf die sich im Ernstfall
// niemand verlassen koennte.
func (generator *Generator) buildObjectivesReport(buildContext context.Context, report *Report) error {
const selectStatement = `
SELECT j.name, j.rpo_seconds, j.rto_seconds, j.status,
(SELECT max(r.completed_at) FROM backup_job_runs r
WHERE r.job_id = j.id AND r.status = 'succeeded') AS letzte_sicherung,
(SELECT avg(EXTRACT(EPOCH FROM (rj.completed_at - rj.started_at)))
FROM restore_jobs rj
JOIN backups b ON b.id = rj.backup_id
JOIN backup_job_runs r ON r.id = b.job_run_id
WHERE r.job_id = j.id AND rj.status = 'succeeded'
AND rj.started_at IS NOT NULL AND rj.completed_at IS NOT NULL) AS gemessene_rto,
(SELECT count(*) FROM restore_jobs rj
JOIN backups b ON b.id = rj.backup_id
JOIN backup_job_runs r ON r.id = b.job_run_id
WHERE r.job_id = j.id AND rj.status = 'succeeded') AS restore_anzahl
FROM backup_jobs j
WHERE j.deleted_at IS NULL
ORDER BY j.name`
objectiveRows, queryError := generator.connectionPool.Query(buildContext, selectStatement)
if queryError != nil {
return fmt.Errorf("die wiederherstellungsziele konnten nicht gelesen werden: %w", queryError)
}
defer objectiveRows.Close()
objectiveTable := Table{
Title: "Aufträge",
Columns: []string{"Auftrag", "Zustand", "RPO-Vorgabe", "Letzte Sicherung", "Alter",
"RPO eingehalten", "RTO-Vorgabe", "Gemessene Dauer", "Messungen"},
Rows: make([][]string, 0, 16),
EmptyNotice: "Es ist kein Sicherungsauftrag eingerichtet.",
}
var (
jobsWithRPO int
jobsViolatingRPO int
jobsWithRTO int
jobsWithRTOMeasurement int
jobsViolatingRTO int
jobsWithoutBackup int
totalJobs int
)
evaluationTime := time.Now().UTC()
for objectiveRows.Next() {
var (
jobName, jobStatus string
rpoSeconds, rtoSeconds *int64
lastSuccessfulBackup *time.Time
measuredRestoreSeconds *float64
restoreMeasurementCount int64
)
if scanError := objectiveRows.Scan(&jobName, &rpoSeconds, &rtoSeconds, &jobStatus,
&lastSuccessfulBackup, &measuredRestoreSeconds, &restoreMeasurementCount); scanError != nil {
return fmt.Errorf("ein auftrag konnte nicht gelesen werden: %w", scanError)
}
totalJobs++
if lastSuccessfulBackup == nil {
jobsWithoutBackup++
}
if rpoSeconds != nil {
jobsWithRPO++
}
if rtoSeconds != nil {
jobsWithRTO++
}
if measuredRestoreSeconds != nil {
jobsWithRTOMeasurement++
if rtoSeconds != nil && *measuredRestoreSeconds > float64(*rtoSeconds) {
jobsViolatingRTO++
}
}
rpoComplianceText := rpoComplianceCell(rpoSeconds, lastSuccessfulBackup, evaluationTime)
if rpoComplianceText == rpoViolatedLabel {
jobsViolatingRPO++
}
objectiveTable.Rows = append(objectiveTable.Rows, []string{
jobName,
jobStatus,
formatOptionalSeconds(rpoSeconds),
formatOptionalTime(lastSuccessfulBackup),
backupAgeCell(lastSuccessfulBackup, evaluationTime),
rpoComplianceText,
formatOptionalSeconds(rtoSeconds),
formatMeasuredRestore(measuredRestoreSeconds),
fmt.Sprintf("%d", restoreMeasurementCount),
})
}
if rowsError := objectiveRows.Err(); rowsError != nil {
return rowsError
}
report.Sections = append(report.Sections, Section{
Title: "Überblick",
Description: "RPO wird laufend gemessen. RTO nur dort, wo tatsächlich eine " +
"Wiederherstellung stattgefunden hat.",
Metrics: []Metric{
KnownMetric("Aufträge", float64(totalJobs), UnitCount),
KnownMetric("Mit RPO-Vorgabe", float64(jobsWithRPO), UnitCount),
KnownMetric("RPO verletzt", float64(jobsViolatingRPO), UnitCount),
KnownMetric("Mit RTO-Vorgabe", float64(jobsWithRTO), UnitCount),
KnownMetric("RTO durch Messung belegt", float64(jobsWithRTOMeasurement), UnitCount),
KnownMetric("RTO-Vorgabe überschritten", float64(jobsViolatingRTO), UnitCount),
KnownMetric("Ohne erfolgreiche Sicherung", float64(jobsWithoutBackup), UnitCount),
},
Tables: []Table{objectiveTable},
})
report.AddNote("Die RTO-Spalte zeigt ausschließlich gemessene Wiederherstellungszeiten, keine " +
"Hochrechnung aus Datenmenge und Durchsatz. Eine solche Schätzung wäre die bequemste " +
"Zahl dieses Berichts und die einzige, auf die sich im Ernstfall niemand verlassen könnte.")
if jobsWithRTO > jobsWithRTOMeasurement {
report.AddNote("Für %d Auftrag/Aufträge mit RTO-Vorgabe gibt es keine gemessene "+
"Wiederherstellung. Ob die Vorgabe eingehalten wird, ist damit unbekannt — nicht "+
"erfüllt und nicht verletzt.", jobsWithRTO-jobsWithRTOMeasurement)
}
if jobsWithoutBackup > 0 {
report.AddNote("%d Auftrag/Aufträge haben noch keine erfolgreiche Sicherung. Für sie "+
"gibt es keinen Wiederherstellungspunkt, unabhängig von jeder Vorgabe.",
jobsWithoutBackup)
}
return nil
}
// rpoViolatedLabel benennt eine verletzte RPO-Vorgabe.
const rpoViolatedLabel = "NEIN"
// rpoComplianceCell beurteilt die Einhaltung einer RPO-Vorgabe.
func rpoComplianceCell(rpoSeconds *int64, lastSuccessfulBackup *time.Time, evaluationTime time.Time) string {
if rpoSeconds == nil {
return "keine Vorgabe"
}
if lastSuccessfulBackup == nil {
return rpoViolatedLabel
}
if evaluationTime.Sub(*lastSuccessfulBackup).Seconds() > float64(*rpoSeconds) {
return rpoViolatedLabel
}
return "ja"
}
// backupAgeCell beschreibt das Alter der letzten erfolgreichen Sicherung.
func backupAgeCell(lastSuccessfulBackup *time.Time, evaluationTime time.Time) string {
if lastSuccessfulBackup == nil {
return "keine Sicherung"
}
return FormatDuration(evaluationTime.Sub(*lastSuccessfulBackup).Seconds())
}
// formatOptionalSeconds stellt eine Vorgabe dar, die fehlen kann.
func formatOptionalSeconds(seconds *int64) string {
if seconds == nil {
return "keine"
}
return FormatDuration(float64(*seconds))
}
// formatMeasuredRestore stellt eine gemessene Wiederherstellungsdauer dar.
func formatMeasuredRestore(measuredSeconds *float64) string {
if measuredSeconds == nil {
return "nicht gemessen"
}
return FormatDuration(*measuredSeconds)
}