initial commit
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
// BIP39-valid word counts
|
||||
var validSeedLengths = map[int]bool{
|
||||
12: true, 15: true, 18: true, 21: true, 24: true,
|
||||
}
|
||||
|
||||
const seedScanMaxFileSize = 1 * 1024 * 1024 // 1MB
|
||||
|
||||
var seedScanFileExts = map[string]bool{
|
||||
".txt": true, ".md": true, ".csv": true, ".tsv": true,
|
||||
".log": true, ".rtf": true, ".json": true, ".xml": true,
|
||||
".env": true, ".cfg": true, ".conf": true, ".ini": true,
|
||||
".bak": true, ".old": true, ".tmp": true, ".note": true,
|
||||
".doc": true, ".nfo": true, ".asc": true, ".key": true,
|
||||
".pem": true, ".p12": true, ".ppk": true, ".der": true, ".pfx": true,
|
||||
}
|
||||
|
||||
// contentScanExts are the text formats read during the file listing scan to
|
||||
// look for sensitive plaintext (BIP39 seed phrases, PEM private keys).
|
||||
var contentScanExts = seedScanFileExts
|
||||
|
||||
// privateKeyRe matches PEM private-key header lines. The optional algorithm
|
||||
// prefix covers RSA/EC/OPENSSH/DSA/ENCRYPTED keys plus the bare PKCS#8 form.
|
||||
var privateKeyRe = regexp.MustCompile(`(?m)^-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----`)
|
||||
|
||||
// contentFileTags reads a small text file once and returns the security tags
|
||||
// that apply to it: "seed" when it contains a BIP39 phrase and "key" when it
|
||||
// contains a PEM private-key header. Returns nil when the file is not a
|
||||
// recognized text format or is too large to scan.
|
||||
func contentFileTags(path, ext string, size int64) []string {
|
||||
if !contentScanExts[ext] || size == 0 || size > seedScanMaxFileSize {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tags []string
|
||||
content := strings.ToLower(string(data))
|
||||
if looksLikeSeedLine(strings.Fields(content)) {
|
||||
tags = append(tags, "seed")
|
||||
} else {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if looksLikeSeedLine(strings.Fields(strings.TrimSpace(line))) {
|
||||
tags = append(tags, "seed")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// PEM headers are case-sensitive, so match against the raw bytes.
|
||||
if privateKeyRe.Match(data) {
|
||||
tags = append(tags, "key")
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// numberedLineRe strips leading "1." / "1)" / "1:" / "1 -" prefixes from numbered lists
|
||||
var numberedLineRe = regexp.MustCompile(`^\s*\d{1,2}\s*[.):\-]\s*`)
|
||||
|
||||
// ScanSeeds searches collected data for BIP39 seed phrases.
|
||||
// Checks file contents, password values, and autofill values.
|
||||
func ScanSeeds(files []types.FileResult, passwords []types.PasswordResult, autofill []types.AutofillResult) []types.SeedResult {
|
||||
seen := make(map[string]bool)
|
||||
var results []types.SeedResult
|
||||
|
||||
// Scan files
|
||||
for _, f := range files {
|
||||
if f.Size > seedScanMaxFileSize || f.Size == 0 {
|
||||
continue
|
||||
}
|
||||
if !seedScanFileExts[f.Ext] {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(f.Path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, phrase := range extractSeedPhrases(string(data)) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "file",
|
||||
Path: f.Path,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan passwords
|
||||
for _, p := range passwords {
|
||||
for _, phrase := range extractSeedPhrases(p.Password) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "password",
|
||||
Path: p.URL,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, phrase := range extractSeedPhrases(p.Username) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "password",
|
||||
Path: p.URL,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan autofill values
|
||||
for _, a := range autofill {
|
||||
for _, phrase := range extractSeedPhrases(a.Value) {
|
||||
if !seen[phrase] {
|
||||
seen[phrase] = true
|
||||
results = append(results, types.SeedResult{
|
||||
Source: "autofill",
|
||||
Path: a.Name,
|
||||
Phrase: phrase,
|
||||
Words: len(strings.Fields(phrase)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// extractSeedPhrases finds all BIP39-like seed phrases in text content.
|
||||
// Handles: space-separated, comma-separated, numbered lists, newline-separated.
|
||||
func extractSeedPhrases(content string) []string {
|
||||
if len(content) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content = strings.ToLower(content)
|
||||
var found []string
|
||||
|
||||
// Strategy 1: full content as one phrase (file contains only the seed)
|
||||
if phrase := tryExtractPhrase(content); phrase != "" {
|
||||
found = append(found, phrase)
|
||||
return found
|
||||
}
|
||||
|
||||
// Strategy 2: line-by-line (seed on one line)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if phrase := tryExtractPhrase(line); phrase != "" {
|
||||
found = append(found, phrase)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: numbered list — collect words from "1. word\n2. word\n..."
|
||||
if phrase := tryNumberedList(content); phrase != "" {
|
||||
if !containsPhrase(found, phrase) {
|
||||
found = append(found, phrase)
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: comma-separated words
|
||||
if strings.Contains(content, ",") {
|
||||
normalized := strings.ReplaceAll(content, ",", " ")
|
||||
if phrase := tryExtractPhrase(normalized); phrase != "" {
|
||||
if !containsPhrase(found, phrase) {
|
||||
found = append(found, phrase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// tryExtractPhrase checks if text contains a valid seed phrase
|
||||
func tryExtractPhrase(text string) string {
|
||||
words := strings.Fields(text)
|
||||
if isValidSeedPhrase(words) {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// Try sliding window for phrases embedded in longer text
|
||||
for _, count := range []int{24, 21, 18, 15, 12} {
|
||||
if len(words) < count {
|
||||
continue
|
||||
}
|
||||
for i := 0; i <= len(words)-count; i++ {
|
||||
window := words[i : i+count]
|
||||
if isValidSeedPhrase(window) {
|
||||
return strings.Join(window, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tryNumberedList extracts words from numbered list format:
|
||||
// "1. abandon\n2. ability\n3. able\n..."
|
||||
func tryNumberedList(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var words []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cleaned := numberedLineRe.ReplaceAllString(line, "")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned == "" {
|
||||
continue
|
||||
}
|
||||
// Each numbered line should have exactly one word
|
||||
lineWords := strings.Fields(cleaned)
|
||||
if len(lineWords) == 1 && isBIP39Word(lineWords[0]) {
|
||||
words = append(words, lineWords[0])
|
||||
}
|
||||
}
|
||||
if isValidSeedPhrase(words) {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isValidSeedPhrase(words []string) bool {
|
||||
if !validSeedLengths[len(words)] {
|
||||
return false
|
||||
}
|
||||
for _, w := range words {
|
||||
if !isBIP39Word(w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isBIP39Word checks if a word matches BIP39 characteristics:
|
||||
// lowercase a-z only, 3-8 characters.
|
||||
func isBIP39Word(w string) bool {
|
||||
if len(w) < 3 || len(w) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range w {
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsPhrase(phrases []string, phrase string) bool {
|
||||
for _, p := range phrases {
|
||||
if p == phrase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user