syncova-backup/packages/platform/secretscan/secretscan.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

299 lines
8.8 KiB
Go

// Package secretscan durchsucht den Quellbestand nach versehentlich abgelegten
// Geheimnissen (SYNCOVA_IMPLEMENTATION_PLAN.md §21).
//
// Der Scanner ist bewusst **im Projekt** und nicht als Fremdwerkzeug in einer
// Anleitung: Er läuft dadurch bei jedem `make test` mit. Was nur in einer
// Anleitung steht, wird nach zwei Wochen nicht mehr ausgeführt — und ein
// Geheimnis, das einmal in der Versionsgeschichte liegt, holt niemand mehr
// zurück.
//
// Er ersetzt kein Werkzeug wie gitleaks, das die gesamte Historie durchsucht.
// Er beantwortet die Frage, die vor jedem Commit zählt: Liegt jetzt gerade ein
// Geheimnis im Arbeitsverzeichnis?
package secretscan
import (
"bufio"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
// Finding ist ein Fund des Scanners.
type Finding struct {
// FilePath ist der Pfad der betroffenen Datei.
FilePath string
// LineNumber ist die Zeilennummer, beginnend bei eins.
LineNumber int
// RuleName benennt die Regel, die angeschlagen hat.
RuleName string
// Excerpt ist ein gekürzter Ausschnitt der Fundstelle.
//
// Gekürzt und nie vollständig: Ein Scanner, der das gefundene Geheimnis in
// voller Länge in ein Prüfprotokoll schreibt, hat es soeben ein zweites Mal
// veröffentlicht.
Excerpt string
}
// String beschreibt einen Fund.
func (finding Finding) String() string {
return fmt.Sprintf("%s:%d [%s] %s", finding.FilePath, finding.LineNumber,
finding.RuleName, finding.Excerpt)
}
// rule ist eine Suchregel des Scanners.
type rule struct {
// name benennt die Regel im Fund.
name string
// pattern ist der Suchausdruck.
pattern *regexp.Regexp
}
// scanRules sind die Muster, nach denen gesucht wird.
//
// Sie zielen auf die Formen, in denen ein Geheimnis versehentlich in den Code
// gerät: eine zugewiesene Zeichenkette, ein privater Schlüssel, ein Token eines
// bekannten Dienstes. Auf Entropie zu prüfen wäre der naheliegende Zusatz und
// erzeugte hier vor allem Fehlalarme — dieses Projekt ist voller Prüfsummen,
// UUIDs und Testvektoren, die von einem Geheimnis nicht zu unterscheiden sind.
var scanRules = []rule{
{
name: "privater Schlüssel",
pattern: regexp.MustCompile(
`-----BEGIN (RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY-----`),
},
{
name: "zugewiesenes Passwort",
// Ein zugewiesener Wert von mindestens acht Zeichen, der nicht wie ein
// Platzhalter aussieht.
pattern: regexp.MustCompile(
`(?i)(password|passwort|passwd|secret|api[_-]?key|apikey|token)\s*[:=]\s*["'` + "`" + `][^"'` + "`" + `]{8,}["'` + "`" + `]`),
},
{
name: "AWS-Zugangsschlüssel",
pattern: regexp.MustCompile(`AKIA[0-9A-Z]{16}`),
},
{
name: "GitHub-Token",
pattern: regexp.MustCompile(`gh[pousr]_[A-Za-z0-9]{36,}`),
},
{
name: "Slack-Token",
pattern: regexp.MustCompile(`xox[baprs]-[0-9A-Za-z-]{10,}`),
},
{
name: "Verbindungszeichenkette mit Passwort",
// postgres://benutzer:geheim@host — der klassische Fund.
pattern: regexp.MustCompile(`(?i)(postgres|postgresql|mysql|mongodb|redis|amqp)://[^:\s/]+:[^@\s]{4,}@`),
},
}
// placeholderPattern erkennt offensichtliche Platzhalter.
//
// Ohne diese Ausnahme meldete der Scanner jede Beispielkonfiguration und jede
// Testfixtur — und ein Prüfwerkzeug, das grundlos Alarm schlägt, wird bald
// nicht mehr ernst genommen. Dieselbe Überlegung wie beim Integritätsscan
// (Phase 2) und bei der Ransomware-Heuristik (Phase 16).
var placeholderPattern = regexp.MustCompile(
`(?i)(beispiel|example|placeholder|platzhalter|dummy|changeme|xxx+|\.\.\.|` +
`your[_-]|<[^>]+>|\$\{[^}]+\}|\$[A-Z_]+|redacted|geschwaerzt|\*{4,})`)
// Options steuern den Lauf des Scanners.
type Options struct {
// RootPath ist das zu durchsuchende Verzeichnis.
RootPath string
// SkipDirectories sind Verzeichnisnamen, die übersprungen werden.
SkipDirectories []string
// SkipFileSuffixes sind Dateiendungen, die übersprungen werden.
SkipFileSuffixes []string
}
// DefaultSkipDirectories sind die Verzeichnisse ohne eigenen Quellcode.
//
// node_modules steht hier aus demselben Grund wie im Makefile: Es enthält
// fremden Code samt dessen Testfixturen, und ein Fund darin sagt nichts über
// diese Anlage aus.
var DefaultSkipDirectories = []string{
".git", "node_modules", "bin", "dist", "coverage", ".idea", ".vscode",
}
// DefaultSkipFileSuffixes sind Dateien ohne durchsuchbaren Text.
var DefaultSkipFileSuffixes = []string{
".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".zip", ".gz", ".bin",
".woff", ".woff2", ".ttf", ".map", ".sum",
}
// maximumScannedFileSize begrenzt die Größe einer durchsuchten Datei.
//
// Zwei Megabyte: Größere Dateien in einem Quellbestand sind Daten, keine
// Quelltexte — und ein Scanner, der eine 500-MB-Testdatei zeilenweise liest,
// bringt die Prüfung zum Stehen.
const maximumScannedFileSize = 2 * 1024 * 1024
// maximumExcerptLength begrenzt die Länge eines Ausschnitts.
const maximumExcerptLength = 60
// Scan durchsucht ein Verzeichnis nach Geheimnissen.
func Scan(scanOptions Options) ([]Finding, error) {
if scanOptions.RootPath == "" {
return nil, fmt.Errorf("es wurde kein wurzelverzeichnis angegeben")
}
skipDirectories := scanOptions.SkipDirectories
if skipDirectories == nil {
skipDirectories = DefaultSkipDirectories
}
skipSuffixes := scanOptions.SkipFileSuffixes
if skipSuffixes == nil {
skipSuffixes = DefaultSkipFileSuffixes
}
findings := make([]Finding, 0, 8)
walkError := filepath.WalkDir(scanOptions.RootPath,
func(currentPath string, directoryEntry fs.DirEntry, walkError error) error {
if walkError != nil {
return nil
}
if directoryEntry.IsDir() {
if containsName(skipDirectories, directoryEntry.Name()) {
return filepath.SkipDir
}
return nil
}
if hasAnySuffix(directoryEntry.Name(), skipSuffixes) {
return nil
}
fileInformation, statError := directoryEntry.Info()
if statError != nil || fileInformation.Size() > maximumScannedFileSize {
return nil
}
fileFindings, scanError := scanFile(currentPath, scanOptions.RootPath)
if scanError != nil {
return nil
}
findings = append(findings, fileFindings...)
return nil
})
if walkError != nil {
return nil, fmt.Errorf("der quellbestand liess sich nicht durchsuchen: %w", walkError)
}
return findings, nil
}
// scanFile durchsucht eine einzelne Datei.
func scanFile(filePath string, rootPath string) ([]Finding, error) {
fileHandle, openError := os.Open(filePath)
if openError != nil {
return nil, openError
}
defer func() { _ = fileHandle.Close() }()
relativePath, relativeError := filepath.Rel(rootPath, filePath)
if relativeError != nil {
relativePath = filePath
}
// Der Scanner prüft sich selbst nicht: Seine Muster enthalten
// naturgemäß genau die Zeichenfolgen, nach denen er sucht.
if strings.Contains(relativePath, filepath.Join("platform", "secretscan")) {
return nil, nil
}
findings := make([]Finding, 0, 2)
lineScanner := bufio.NewScanner(fileHandle)
lineScanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
lineNumber := 0
for lineScanner.Scan() {
lineNumber++
currentLine := lineScanner.Text()
if isIgnoredLine(currentLine) {
continue
}
for _, scanRule := range scanRules {
matchedText := scanRule.pattern.FindString(currentLine)
if matchedText == "" {
continue
}
if placeholderPattern.MatchString(matchedText) {
continue
}
findings = append(findings, Finding{
FilePath: relativePath,
LineNumber: lineNumber,
RuleName: scanRule.name,
Excerpt: shortenExcerpt(matchedText),
})
}
}
return findings, lineScanner.Err()
}
// ignoreMarker hebt eine Zeile ausdrücklich von der Prüfung aus.
//
// Der Ausweg ist nötig und bewusst umständlich benannt: Wer ihn setzt, tut es
// absichtlich, und er lässt sich in einer Durchsicht wiederfinden.
const ignoreMarker = "secretscan:erlaubt"
// isIgnoredLine meldet eine ausdrücklich ausgenommene Zeile.
func isIgnoredLine(currentLine string) bool {
return strings.Contains(currentLine, ignoreMarker)
}
// shortenExcerpt kürzt einen Ausschnitt für die Ausgabe.
//
// Ein Scanner, der das gefundene Geheimnis in voller Länge in ein
// Prüfprotokoll schreibt, hat es soeben ein zweites Mal veröffentlicht — und
// Prüfprotokolle landen in Logdateien, in Ticketsystemen und in Chats.
func shortenExcerpt(matchedText string) string {
if len(matchedText) <= maximumExcerptLength {
return matchedText
}
return matchedText[:maximumExcerptLength] + "…"
}
// containsName meldet, ob ein Name in der Liste steht.
func containsName(names []string, candidate string) bool {
for _, name := range names {
if name == candidate {
return true
}
}
return false
}
// hasAnySuffix meldet, ob der Name auf eine der Endungen endet.
func hasAnySuffix(fileName string, suffixes []string) bool {
lowerName := strings.ToLower(fileName)
for _, suffix := range suffixes {
if strings.HasSuffix(lowerName, suffix) {
return true
}
}
return false
}