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) }