syncova-backup/packages/security/checks.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

806 lines
29 KiB
Go

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
}