// 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 }