package security import ( "context" "fmt" "time" "github.com/jackc/pgx/v5/pgxpool" ) // Gewichte der Pruefbereiche. // // Sie stehen beisammen, weil ihre Verhaeltnisse die Aussage der Gesamtnote // bestimmen. Die Reihenfolge folgt einer Ueberzeugung: Was den Verlust der // Backups verhindert, wiegt schwerer als was ihn nur erschwert. const ( // weightImmutability ist das Gewicht des Loeschschutzes. // // Das hoechste: Ohne ihn genuegt ein kompromittiertes Konto, um alles zu // vernichten — Verschluesselung und MFA halten dann niemanden auf. weightImmutability = 20 // weightRansomwareRisk ist das Gewicht der Widerstandsfaehigkeit. weightRansomwareRisk = 15 // weightMultiFactor ist das Gewicht des zweiten Faktors. weightMultiFactor = 15 // weightEncryption ist das Gewicht der Verschluesselung. weightEncryption = 12 // weightOffsite ist das Gewicht der Kopie an einem zweiten Ort. weightOffsite = 10 // weightAccessControl ist das Gewicht der Rechteverteilung. weightAccessControl = 10 // weightAgentSecurity ist das Gewicht der Agentenabsicherung. weightAgentSecurity = 8 // weightAudit ist das Gewicht des Auditprotokolls. weightAudit = 5 // weightUpdates ist das Gewicht des Versionsstands. weightUpdates = 3 // weightCertificates ist das Gewicht der Zertifikate. weightCertificates = 2 ) // Inspector prueft die Sicherheitslage gegen die Control Plane. type Inspector struct { // connectionPool ist der Datenbankpool der Control Plane. connectionPool *pgxpool.Pool // buildVersion ist die Version des laufenden Dienstes. buildVersion string } // NewInspector erzeugt die Sicherheitspruefung. func NewInspector(connectionPool *pgxpool.Pool, buildVersion string) *Inspector { return &Inspector{connectionPool: connectionPool, buildVersion: buildVersion} } // Inspect erhebt die vollstaendige Sicherheitslage. func (inspector *Inspector) Inspect(inspectContext context.Context) (*Assessment, error) { areaCheckers := []func(context.Context) (AreaResult, error){ inspector.checkImmutability, inspector.checkRansomwareRisk, inspector.checkMultiFactor, inspector.checkEncryption, inspector.checkOffsite, inspector.checkAccessControl, inspector.checkAgentSecurity, inspector.checkAudit, inspector.checkUpdates, inspector.checkCertificates, } assessment := &Assessment{ Areas: make([]AreaResult, 0, len(areaCheckers)), UncheckedAreas: make([]string, 0, 2), EvaluatedAt: time.Now().UTC(), } for _, checkArea := range areaCheckers { areaResult, checkError := checkArea(inspectContext) if checkError != nil { return nil, checkError } // Ein unvollstaendiger Befund faellt sonst erst im Betrieb auf — dann, // wenn jemand ratlos vor „Sicherheitsproblem erkannt" steht. for _, finding := range areaResult.Findings { if validationError := finding.Validate(); validationError != nil { return nil, validationError } } assessment.Areas = append(assessment.Areas, areaResult) } finalizeAssessment(assessment) return assessment, nil } // finalizeAssessment rechnet die Gesamtbewertung aus den Bereichen. func finalizeAssessment(assessment *Assessment) { totalPoints := 0 maximumPoints := 0 for areaIndex := range assessment.Areas { areaResult := &assessment.Areas[areaIndex] if !areaResult.Available { // Ein ungeprueft Bereich geht **nicht** in die Rechnung ein, weder // positiv noch negativ. Ihn als bestanden zu werten waere // Schoenfaerberei, ihn als durchgefallen zu werten waere eine // Behauptung — beides waere erfunden. assessment.UncheckedAreaCount++ assessment.UncheckedAreas = append(assessment.UncheckedAreas, areaResult.Title) continue } totalPoints += areaResult.EarnedPoints maximumPoints += areaResult.Weight for _, finding := range areaResult.Findings { switch finding.Severity { case SeverityCritical: assessment.CriticalFindingCount++ case SeverityHigh: assessment.HighFindingCount++ } } } assessment.MaximumScore = maximumPoints if maximumPoints > 0 { assessment.Score = totalPoints * 100 / maximumPoints } assessment.Grade = gradeOf(assessment.Score, assessment.CriticalFindingCount) assessment.Summary = summaryOf(assessment) } // gradeOf bildet die Einstufung der Sicherheitslage. func gradeOf(score int, criticalFindingCount int) string { // Ein kritischer Befund deckelt die Einstufung. Eine Anlage, bei der ein // einziges gestohlenes Konto alle Backups vernichten kann, ist nicht // „gut abgesichert mit kleinem Mangel". if criticalFindingCount > 0 { return "unzureichend" } switch { case score >= 90: return "gut" case score >= 70: return "brauchbar" case score >= 50: return "verbesserungsbeduerftig" default: return "unzureichend" } } // summaryOf fasst die Lage in einem Satz zusammen. func summaryOf(assessment *Assessment) string { if assessment.CriticalFindingCount > 0 { return fmt.Sprintf("%d kritische Befunde. Solange sie bestehen, ist die Anlage nicht "+ "sicher zu betreiben — die Prozentzahl daneben aendert daran nichts.", assessment.CriticalFindingCount) } if assessment.UncheckedAreaCount > 2 { return fmt.Sprintf("%d Prozent, aber %d von %d Bereichen konnten nicht geprueft werden. "+ "Die Zahl ist eine Vermutung, keine Aussage.", assessment.Score, assessment.UncheckedAreaCount, len(assessment.Areas)) } if assessment.HighFindingCount > 0 { return fmt.Sprintf("%d Prozent (%s). %d Befunde verlangen eine Aenderung.", assessment.Score, assessment.Grade, assessment.HighFindingCount) } return fmt.Sprintf("%d Prozent (%s).", assessment.Score, assessment.Grade) } // checkMultiFactor prueft den zweiten Faktor der Konten. func (inspector *Inspector) checkMultiFactor(checkContext context.Context) (AreaResult, error) { // Gezaehlt werden nur aktive Konten: Ein gesperrtes ohne zweiten Faktor ist // kein Risiko. const mfaStatement = ` SELECT count(*) FILTER (WHERE u.mfa_enabled), count(*), count(*) FILTER (WHERE NOT u.mfa_enabled AND EXISTS ( SELECT 1 FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id WHERE ur.user_id = u.id AND p.name IN ('backups.delete', 'immutability.manage', 'users.write') )) FROM users u WHERE u.status = 'active'` var ( protectedCount int64 activeCount int64 privilegedNoMFA int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, mfaStatement).Scan( &protectedCount, &activeCount, &privilegedNoMFA); scanError != nil { return AreaResult{}, fmt.Errorf("der zweite faktor liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaMultiFactor, Title: "Zweiter Faktor", Available: true, Weight: weightMultiFactor, Findings: make([]Finding, 0, 2), } if activeCount == 0 { areaResult.Summary = "Es gibt kein aktives Konto." areaResult.EarnedPoints = weightMultiFactor return areaResult, nil } unprotectedCount := activeCount - protectedCount switch { case privilegedNoMFA > 0: // Ein Konto mit Loeschrecht und ohne zweiten Faktor ist der kuerzeste // Weg von einem gestohlenen Passwort zum Totalverlust. areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaMultiFactor, Severity: SeverityCritical, Title: "Konten mit Loeschrecht ohne zweiten Faktor", Explanation: fmt.Sprintf("%d Konten duerfen Backups loeschen oder den Aufbewahrungsschutz "+ "aufheben, sind aber nur durch ein Passwort geschuetzt. Ein gestohlenes Passwort "+ "genuegt damit, um die Sicherungen zu vernichten.", privilegedNoMFA), AffectedObject: fmt.Sprintf("%d von %d aktiven Konten", privilegedNoMFA, activeCount), Recommendation: "Richten Sie fuer diese Konten einen zweiten Faktor ein " + "(POST /api/v1/me/mfa/enroll) oder entziehen Sie ihnen die destruktiven Rechte.", }) case unprotectedCount > 0: areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaMultiFactor, Severity: SeverityHigh, Title: "Konten ohne zweiten Faktor", Explanation: fmt.Sprintf("%d von %d aktiven Konten sind nur durch ein Passwort "+ "geschuetzt. Ein Passwort allein haelt niemanden auf, der es kennt.", unprotectedCount, activeCount), AffectedObject: fmt.Sprintf("%d von %d aktiven Konten", unprotectedCount, activeCount), Recommendation: "Richten Sie den zweiten Faktor je Konto ueber " + "POST /api/v1/me/mfa/enroll ein.", }) } protectedPercentage := float64(protectedCount) * 100 / float64(activeCount) areaResult.EarnedPoints = int(float64(weightMultiFactor) * protectedPercentage / 100) if privilegedNoMFA > 0 { areaResult.EarnedPoints = 0 } areaResult.Summary = fmt.Sprintf("%d von %d aktiven Konten haben einen zweiten Faktor.", protectedCount, activeCount) return areaResult, nil } // checkEncryption prueft die Verschluesselung der Backups. func (inspector *Inspector) checkEncryption(checkContext context.Context) (AreaResult, error) { const encryptionStatement = ` SELECT count(*) FILTER (WHERE encrypted_bytes > 0), count(*) FROM backups WHERE status = 'complete' AND deleted_at IS NULL` var ( encryptedCount int64 totalCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, encryptionStatement).Scan( &encryptedCount, &totalCount); scanError != nil { return AreaResult{}, fmt.Errorf("die verschluesselung liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaEncryption, Title: "Verschluesselung", Available: true, Weight: weightEncryption, Findings: make([]Finding, 0, 1), } if totalCount == 0 { areaResult.Summary = "Es gibt keinen Wiederherstellungspunkt." areaResult.EarnedPoints = weightEncryption return areaResult, nil } unencryptedCount := totalCount - encryptedCount if unencryptedCount > 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaEncryption, Severity: SeverityHigh, Title: "Unverschluesselte Wiederherstellungspunkte", Explanation: fmt.Sprintf("%d von %d Wiederherstellungspunkten sind unverschluesselt "+ "abgelegt. Wer an die Dateien kommt, liest die gesicherten Daten im Klartext — "+ "auch ohne Zugang zu dieser Anlage.", unencryptedCount, totalCount), AffectedObject: fmt.Sprintf("%d Wiederherstellungspunkte", unencryptedCount), Recommendation: "Neue Sicherungen werden verschluesselt abgelegt, sobald ein " + "Datenschluessel vorliegt. Bestehende unverschluesselte Punkte lassen sich nicht " + "nachtraeglich verschluesseln — legen Sie sie in einem verschluesselten Repository neu an.", }) } encryptedPercentage := float64(encryptedCount) * 100 / float64(totalCount) areaResult.EarnedPoints = int(float64(weightEncryption) * encryptedPercentage / 100) areaResult.Summary = fmt.Sprintf("%d von %d Wiederherstellungspunkten sind verschluesselt.", encryptedCount, totalCount) return areaResult, nil } // checkImmutability prueft den Loeschschutz der Repositories. func (inspector *Inspector) checkImmutability(checkContext context.Context) (AreaResult, error) { const immutabilityStatement = ` SELECT count(*) FILTER (WHERE hardened), count(*) FILTER (WHERE enforcement_level = 'filesystem'), count(*) FILTER (WHERE enforcement_level IS NULL OR enforcement_level = ''), count(*) FROM repositories WHERE status <> 'unavailable'` var ( hardenedCount int64 enforcedCount int64 unmeasuredCount int64 totalCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, immutabilityStatement).Scan( &hardenedCount, &enforcedCount, &unmeasuredCount, &totalCount); scanError != nil { return AreaResult{}, fmt.Errorf("der loeschschutz liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaImmutability, Title: "Loeschschutz", Available: true, Weight: weightImmutability, Findings: make([]Finding, 0, 2), } if totalCount == 0 { areaResult.Summary = "Es ist kein erreichbares Repository eingerichtet." areaResult.EarnedPoints = 0 return areaResult, nil } if enforcedCount == 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaImmutability, Severity: SeverityCritical, Title: "Kein Repository mit nachgewiesenem Loeschschutz", Explanation: "Bei keinem Repository verhindert das Dateisystem die Loeschung. Ein " + "kompromittiertes Konto oder Schadsoftware mit Dateizugriff kann alle Sicherungen " + "entfernen — Verschluesselung und zweiter Faktor halten dann niemanden auf.", AffectedObject: fmt.Sprintf("alle %d erreichbaren Repositories", totalCount), Recommendation: "Legen Sie ein gehaertetes Repository an (syncova-repo create --hardened) " + "und messen Sie die Durchsetzungsstufe ueber " + "POST /api/v1/repositories/{id}/enforcement/measure.", }) } if unmeasuredCount > 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaImmutability, Severity: SeverityWarning, Title: "Loeschschutz nicht gemessen", Explanation: fmt.Sprintf("Bei %d von %d Repositories wurde nie geprueft, was der Speicher "+ "tatsaechlich verhindert. Ein als gehaertet gefuehrtes Repository ohne Messung ist "+ "eine Behauptung.", unmeasuredCount, totalCount), AffectedObject: fmt.Sprintf("%d Repositories", unmeasuredCount), Recommendation: "Messen Sie die Durchsetzungsstufe je Repository ueber " + "POST /api/v1/repositories/{id}/enforcement/measure.", }) } enforcedPercentage := float64(enforcedCount) * 100 / float64(totalCount) areaResult.EarnedPoints = int(float64(weightImmutability) * enforcedPercentage / 100) areaResult.Summary = fmt.Sprintf("%d von %d Repositories haben einen nachgewiesenen Loeschschutz.", enforcedCount, totalCount) return areaResult, nil } // checkOffsite prueft die Kopie an einem zweiten Ort. func (inspector *Inspector) checkOffsite(checkContext context.Context) (AreaResult, error) { // Die Funktion gibt es nicht. Sie als bestanden zu werten waere die // bequeme und falsche Entscheidung — dieselbe Ueberlegung wie bei // HasOffsiteCopy in der Recovery Assurance (Phase 10). return AreaResult{ Area: AreaOffsite, Title: "Kopie an einem zweiten Ort", Available: false, Weight: weightOffsite, UnavailableReason: "Sekundaere Kopien sind nicht umgesetzt (Backup Copy, PROMPT.md §17). " + "Die Anlage kann deshalb nicht sagen, ob es eine Kopie an einem zweiten Ort gibt. " + "Ohne sie vernichtet ein Brand oder ein Verschluesselungsangriff auf den Standort " + "beide Seiten der 3-2-1-Regel gleichzeitig.", Findings: []Finding{}, Summary: "Nicht pruefbar.", }, nil } // checkAudit prueft das Auditprotokoll. func (inspector *Inspector) checkAudit(checkContext context.Context) (AreaResult, error) { const auditStatement = ` SELECT count(*), max(created_at), count(*) FILTER (WHERE result = 'denied' AND created_at > now() - interval '24 hours') FROM audit_events` var ( eventCount int64 lastEventAt *time.Time recentDenialCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, auditStatement).Scan( &eventCount, &lastEventAt, &recentDenialCount); scanError != nil { return AreaResult{}, fmt.Errorf("das auditprotokoll liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaAudit, Title: "Auditprotokoll", Available: true, Weight: weightAudit, Findings: make([]Finding, 0, 2), } if eventCount == 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaAudit, Severity: SeverityHigh, Title: "Kein Auditereignis vorhanden", Explanation: "Das Auditprotokoll ist leer. Eine Anlage ohne Protokoll kann im " + "Nachhinein nicht sagen, wer was getan hat — auch nicht nach einem Vorfall.", AffectedObject: "Auditprotokoll", Recommendation: "Pruefen Sie, ob der Dienst mit einer erreichbaren Datenbank laeuft. " + "Das Protokoll wird bei jeder sicherheitsrelevanten Handlung geschrieben.", }) areaResult.Summary = "Das Auditprotokoll ist leer." return areaResult, nil } areaResult.EarnedPoints = weightAudit // Abgewehrte Zugriffe sind kein Fehler der Anlage, aber eine Auskunft: Wer // wiederholt gegen eine Berechtigungsgrenze laeuft, tut entweder etwas // Falsches oder etwas Boeses. if recentDenialCount > 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaAudit, Severity: SeverityInformation, Title: "Abgewehrte Zugriffe in den letzten 24 Stunden", Explanation: fmt.Sprintf("%d Zugriffe wurden mangels Berechtigung abgewiesen. Das ist "+ "kein Fehler der Anlage — sehen Sie dennoch nach, ob dahinter ein falsch "+ "eingerichtetes Konto oder ein Versuch steckt.", recentDenialCount), AffectedObject: "Auditprotokoll", Recommendation: "Sehen Sie die Ereignisse unter GET /api/v1/audit-events durch.", }) } lastEventText := "unbekannt" if lastEventAt != nil { lastEventText = lastEventAt.Format(time.RFC3339) } areaResult.Summary = fmt.Sprintf("%d Ereignisse protokolliert, zuletzt %s.", eventCount, lastEventText) return areaResult, nil } // checkCertificates prueft die Zertifikate der Agenten. func (inspector *Inspector) checkCertificates(checkContext context.Context) (AreaResult, error) { // Dieselbe Lage wie bei der Meldungsregel aus Phase 14: Die Tabelle wird von // keiner Stelle beschrieben. Ein Bereich, der dauerhaft „alles in Ordnung" // meldet, ist gefaehrlicher als einer, der sich als ungeprueft ausweist. return AreaResult{ Area: AreaCertificates, Title: "Zertifikate der Agenten", Available: false, Weight: weightCertificates, UnavailableReason: "Die Agenten weisen sich ueber Betriebstokens aus, nicht ueber " + "Zertifikate. Die Tabelle agent_certificates wird von keiner Stelle beschrieben; " + "eine Pruefung koennte nur bestaetigen, dass nichts da ist.", Findings: []Finding{}, Summary: "Nicht pruefbar.", }, nil } // checkAgentSecurity prueft die Absicherung der Agenten. func (inspector *Inspector) checkAgentSecurity(checkContext context.Context) (AreaResult, error) { const agentStatement = ` SELECT count(*) FILTER (WHERE a.status = 'active'), count(*) FILTER (WHERE a.status = 'revoked'), count(*), (SELECT count(*) FROM agent_tokens t WHERE t.revoked_at IS NULL AND t.issued_at < now() - interval '365 days'), (SELECT count(*) FROM agent_enrollment_tokens e WHERE e.used_at IS NULL AND e.expires_at > now()) FROM agents a` var ( activeCount int64 revokedCount int64 totalCount int64 oldTokenCount int64 openEnrollmentCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, agentStatement).Scan( &activeCount, &revokedCount, &totalCount, &oldTokenCount, &openEnrollmentCount); scanError != nil { return AreaResult{}, fmt.Errorf("die agenten liessen sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaAgentSecurity, Title: "Absicherung der Agenten", Available: true, Weight: weightAgentSecurity, Findings: make([]Finding, 0, 2), Summary: fmt.Sprintf("%d aktive Agenten, %d widerrufen.", activeCount, revokedCount), } areaResult.EarnedPoints = weightAgentSecurity if oldTokenCount > 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaAgentSecurity, Severity: SeverityWarning, Title: "Betriebstokens aelter als ein Jahr", Explanation: fmt.Sprintf("%d Betriebstokens sind seit ueber einem Jahr in Gebrauch. "+ "Je laenger ein Token gilt, desto laenger nutzt es einem Angreifer, der es einmal "+ "erbeutet hat.", oldTokenCount), AffectedObject: fmt.Sprintf("%d Betriebstokens", oldTokenCount), Recommendation: "Erneuern Sie die Zugangsdaten ueber " + "POST /api/v1/agents/{id}/rotate-credentials.", }) areaResult.EarnedPoints = weightAgentSecurity / 2 } if openEnrollmentCount > 0 { // Ein offenes Aufnahme-Token nimmt ein beliebiges System in die // Infrastruktur auf. Es gilt nur eine Stunde, aber in dieser Stunde // genuegt es. areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaAgentSecurity, Severity: SeverityInformation, Title: "Offene Aufnahme-Tokens", Explanation: fmt.Sprintf("%d Aufnahme-Tokens sind ausgestellt und noch nicht "+ "eingeloest. Wer eines davon hat, kann ein System als Agent anmelden.", openEnrollmentCount), AffectedObject: fmt.Sprintf("%d Aufnahme-Tokens", openEnrollmentCount), Recommendation: "Loesen Sie die Tokens zeitnah ein; nicht genutzte laufen nach einer " + "Stunde von selbst ab.", }) } return areaResult, nil } // checkUpdates prueft den Versionsstand. func (inspector *Inspector) checkUpdates(checkContext context.Context) (AreaResult, error) { const versionStatement = ` SELECT count(*) FILTER (WHERE version <> $1), count(*) FROM agents WHERE status = 'active'` var ( mismatchedCount int64 activeCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, versionStatement, inspector.buildVersion).Scan(&mismatchedCount, &activeCount); scanError != nil { return AreaResult{}, fmt.Errorf("der versionsstand liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaUpdates, Title: "Versionsstand", Available: true, Weight: weightUpdates, Findings: make([]Finding, 0, 1), } areaResult.EarnedPoints = weightUpdates if mismatchedCount > 0 { // Verglichen wird gegen die Version des Dienstes, **nicht** gegen eine // Liste bekannter Schwachstellen: Eine solche gibt es hier nicht, und // eine erfundene waere schlimmer als keine. areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaUpdates, Severity: SeverityWarning, Title: "Agenten mit abweichender Version", Explanation: fmt.Sprintf("%d von %d aktiven Agenten laufen nicht auf der Version des "+ "Dienstes (%s). Abweichende Versionen koennen Fehler enthalten, die hier bereits "+ "behoben sind.", mismatchedCount, activeCount, inspector.buildVersion), AffectedObject: fmt.Sprintf("%d Agenten", mismatchedCount), Recommendation: "Aktualisieren Sie die Agenten auf die Version des Dienstes.", }) areaResult.EarnedPoints = 0 } areaResult.Summary = fmt.Sprintf("%d von %d aktiven Agenten laufen auf Version %s.", activeCount-mismatchedCount, activeCount, inspector.buildVersion) return areaResult, nil } // checkAccessControl prueft die Verteilung destruktiver Rechte. func (inspector *Inspector) checkAccessControl(checkContext context.Context) (AreaResult, error) { const accessStatement = ` SELECT (SELECT count(DISTINCT ur.user_id) FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id JOIN users u ON u.id = ur.user_id WHERE p.name = 'backups.delete' AND u.status = 'active'), (SELECT count(DISTINCT ur.user_id) FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id JOIN users u ON u.id = ur.user_id WHERE p.name = 'immutability.manage' AND u.status = 'active'), (SELECT count(*) FROM users WHERE status = 'active')` var ( deleteCapableCount int64 immutabilityAdminCount int64 activeUserCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, accessStatement).Scan( &deleteCapableCount, &immutabilityAdminCount, &activeUserCount); scanError != nil { return AreaResult{}, fmt.Errorf("die rechteverteilung liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaAccessControl, Title: "Verteilung destruktiver Rechte", Available: true, Weight: weightAccessControl, Findings: make([]Finding, 0, 1), } areaResult.EarnedPoints = weightAccessControl if activeUserCount == 0 { areaResult.Summary = "Es gibt kein aktives Konto." return areaResult, nil } // Der Anteil zaehlt, nicht die absolute Zahl: Zwei von zwei Konten mit // Loeschrecht ist etwas anderes als zwei von zwanzig. deletePercentage := float64(deleteCapableCount) * 100 / float64(activeUserCount) if deletePercentage > 50 && activeUserCount > 1 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaAccessControl, Severity: SeverityWarning, Title: "Loeschrecht weit verteilt", Explanation: fmt.Sprintf("%d von %d aktiven Konten duerfen Backups loeschen. Je mehr "+ "Konten das koennen, desto mehr Wege gibt es, die Sicherungen zu verlieren — "+ "durch Fehler wie durch Angriff.", deleteCapableCount, activeUserCount), AffectedObject: fmt.Sprintf("%d von %d aktiven Konten", deleteCapableCount, activeUserCount), Recommendation: "Entziehen Sie backups.delete den Konten, die es fuer die taegliche " + "Arbeit nicht brauchen.", }) areaResult.EarnedPoints = weightAccessControl / 2 } areaResult.Summary = fmt.Sprintf("%d Konten duerfen loeschen, %d den Aufbewahrungsschutz aendern.", deleteCapableCount, immutabilityAdminCount) return areaResult, nil } // checkRansomwareRisk prueft die Widerstandsfaehigkeit gegen Verschluesselung. func (inspector *Inspector) checkRansomwareRisk(checkContext context.Context) (AreaResult, error) { // Zusammengesetzt aus drei Signalen, die es tatsaechlich gibt: geschuetzte // Wiederherstellungspunkte, gemessener Loeschschutz und offene Verdachtsfaelle // aus dem Meldungswesen (Phase 14). const riskStatement = ` SELECT (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), (SELECT count(*) FROM alerts WHERE rule_name = 'unusual_change_rate' AND status IN ('open', 'acknowledged'))` var ( protectedBackupCount int64 totalBackupCount int64 openSuspicionCount int64 ) if scanError := inspector.connectionPool.QueryRow(checkContext, riskStatement).Scan( &protectedBackupCount, &totalBackupCount, &openSuspicionCount); scanError != nil { return AreaResult{}, fmt.Errorf("die risikolage liess sich nicht pruefen: %w", scanError) } areaResult := AreaResult{ Area: AreaRansomwareRisk, Title: "Widerstandsfaehigkeit gegen Verschluesselungsangriffe", Available: true, Weight: weightRansomwareRisk, Findings: make([]Finding, 0, 2), } if totalBackupCount == 0 { areaResult.Summary = "Es gibt keinen Wiederherstellungspunkt." areaResult.EarnedPoints = 0 return areaResult, nil } if protectedBackupCount == 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaRansomwareRisk, Severity: SeverityCritical, Title: "Kein Wiederherstellungspunkt gegen Loeschung geschuetzt", Explanation: fmt.Sprintf("Keiner der %d Wiederherstellungspunkte steht unter "+ "Aufbewahrungsschutz. Schadsoftware, die Zugriff auf die Ablage erlangt, kann "+ "zuerst die Sicherungen entfernen und danach die Nutzdaten verschluesseln — genau "+ "in dieser Reihenfolge gehen Angreifer vor.", totalBackupCount), AffectedObject: fmt.Sprintf("alle %d Wiederherstellungspunkte", totalBackupCount), Recommendation: "Legen Sie die Sicherungen in einem gehaerteten Repository ab und " + "pruefen Sie den Loeschschutz ueber die Durchsetzungsmessung.", }) } if openSuspicionCount > 0 { areaResult.Findings = append(areaResult.Findings, Finding{ Area: AreaRansomwareRisk, Severity: SeverityHigh, Title: "Offene Verdachtsfaelle auf ungewoehnliche Aenderungsraten", Explanation: fmt.Sprintf("%d Meldungen ueber auffaellig viele neue Daten sind "+ "unerledigt. Massenhafte Verschluesselung sieht so aus — ein grosses Update aber "+ "auch. Ungeklaert bleibt beides gleich unangenehm.", openSuspicionCount), AffectedObject: fmt.Sprintf("%d Meldungen", openSuspicionCount), Recommendation: "Klaeren Sie die Meldungen unter GET /api/v1/alerts, bevor Sie aeltere " + "Wiederherstellungspunkte loeschen.", }) } protectedPercentage := float64(protectedBackupCount) * 100 / float64(totalBackupCount) areaResult.EarnedPoints = int(float64(weightRansomwareRisk) * protectedPercentage / 100) if openSuspicionCount > 0 { areaResult.EarnedPoints /= 2 } areaResult.Summary = fmt.Sprintf("%d von %d Wiederherstellungspunkten sind gegen Loeschung geschuetzt.", protectedBackupCount, totalBackupCount) return areaResult, nil } // CurrentSecurityScore liefert die Bewertung in Prozent. // // Erfuellt die Schnittstelle der Kennzahlenerfassung (Phase 13), damit der // Verlauf der Sicherheitslage als Zeitreihe entsteht. func (inspector *Inspector) CurrentSecurityScore(scoreContext context.Context) (float64, error) { assessment, inspectError := inspector.Inspect(scoreContext) if inspectError != nil { return 0, inspectError } return float64(assessment.Score), nil }