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>
806 lines
29 KiB
Go
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
|
|
}
|