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>
431 lines
16 KiB
Go
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)
|
|
}
|