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>
299 lines
8.8 KiB
Go
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
|
|
}
|