From 2b91472204883a0a0f56ca457996c62bcb3d4bc3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 17 Nov 2020 09:30:55 +0100 Subject: [PATCH 1/2] Add binary metadata getters for windows Also, add generic binary name generator --- utils/osdetail/binmeta.go | 86 +++++++++++++++++++++++++ utils/osdetail/binmeta_default.go | 15 +++++ utils/osdetail/binmeta_test.go | 35 ++++++++++ utils/osdetail/binmeta_windows.go | 96 ++++++++++++++++++++++++++++ utils/osdetail/errors.go | 9 +++ utils/osdetail/powershell_windows.go | 47 ++++++++++++++ utils/osdetail/test/main_windows.go | 33 ++++++++++ 7 files changed, 321 insertions(+) create mode 100644 utils/osdetail/binmeta.go create mode 100644 utils/osdetail/binmeta_default.go create mode 100644 utils/osdetail/binmeta_test.go create mode 100644 utils/osdetail/binmeta_windows.go create mode 100644 utils/osdetail/errors.go create mode 100644 utils/osdetail/powershell_windows.go diff --git a/utils/osdetail/binmeta.go b/utils/osdetail/binmeta.go new file mode 100644 index 0000000..175aea2 --- /dev/null +++ b/utils/osdetail/binmeta.go @@ -0,0 +1,86 @@ +package osdetail + +import ( + "path/filepath" + "regexp" + "strings" +) + +var ( + segmentsSplitter = regexp.MustCompile("[^A-Za-z0-9]*[A-Z]?[a-z0-9]*") + nameOnly = regexp.MustCompile("^[A-Za-z0-9]+$") + delimiters = regexp.MustCompile("^[^A-Za-z0-9]+") +) + +// GenerateBinaryNameFromPath generates a more human readable binary name from +// the given path. This function is used as fallback in the GetBinaryName +// functions. +func GenerateBinaryNameFromPath(path string) string { + // Get file name from path. + _, fileName := filepath.Split(path) + + // Split up into segments. + segments := segmentsSplitter.FindAllString(fileName, -1) + + // Remove last segment if it's an extension. + if len(segments) >= 2 && + strings.HasPrefix(segments[len(segments)-1], ".") { + segments = segments[:len(segments)-1] + } + + // Go through segments and collect name parts. + nameParts := make([]string, 0, len(segments)) + var fragments string + for _, segment := range segments { + // Group very short segments. + if len(segment) <= 3 { + fragments += segment + continue + } else if fragments != "" { + nameParts = append(nameParts, fragments) + fragments = "" + } + + // Add segment to name. + nameParts = append(nameParts, segment) + } + // Add last fragment. + if fragments != "" { + nameParts = append(nameParts, fragments) + } + + // Post-process name parts + for i := range nameParts { + // Remove any leading delimiters. + nameParts[i] = delimiters.ReplaceAllString(nameParts[i], "") + + // Title-case name-only parts. + if nameOnly.MatchString(nameParts[i]) { + nameParts[i] = strings.Title(nameParts[i]) + } + } + + return strings.Join(nameParts, " ") +} + +func cleanFileDescription(fields []string) string { + // If there is a 1 or 2 character delimiter field, only use fields before it. + endIndex := len(fields) + for i, field := range fields { + // Ignore the first field as well as fields with more than two characters. + if i >= 1 && len(field) <= 2 && !nameOnly.MatchString(field) { + endIndex = i + break + } + } + + // Concatenate name + binName := strings.Join(fields[:endIndex], " ") + + // If there are multiple sentences, only use the first. + if strings.Contains(binName, ". ") { + binName = strings.SplitN(binName, ". ", 2)[0] + } + + return binName +} diff --git a/utils/osdetail/binmeta_default.go b/utils/osdetail/binmeta_default.go new file mode 100644 index 0000000..dee29e1 --- /dev/null +++ b/utils/osdetail/binmeta_default.go @@ -0,0 +1,15 @@ +//+build !windows + +package osdetail + +// GetBinaryNameFromSystem queries the operating system for a human readable +// name for the given binary path. +func GetBinaryNameFromSystem(path string) (string, error) { + return "", ErrNotSupported +} + +// GetBinaryIconFromSystem queries the operating system for the associated icon +// for a given binary path. +func GetBinaryIconFromSystem(path string) (string, error) { + return "", ErrNotSupported +} diff --git a/utils/osdetail/binmeta_test.go b/utils/osdetail/binmeta_test.go new file mode 100644 index 0000000..3cb50d4 --- /dev/null +++ b/utils/osdetail/binmeta_test.go @@ -0,0 +1,35 @@ +package osdetail + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateBinaryNameFromPath(t *testing.T) { + assert.Equal(t, "Nslookup", GenerateBinaryNameFromPath("nslookup.exe")) + assert.Equal(t, "System Settings", GenerateBinaryNameFromPath("SystemSettings.exe")) + assert.Equal(t, "One Drive Setup", GenerateBinaryNameFromPath("OneDriveSetup.exe")) + assert.Equal(t, "Msedge", GenerateBinaryNameFromPath("msedge.exe")) + assert.Equal(t, "SIH Client", GenerateBinaryNameFromPath("SIHClient.exe")) + assert.Equal(t, "Openvpn Gui", GenerateBinaryNameFromPath("openvpn-gui.exe")) + assert.Equal(t, "Portmaster Core v0-1-2", GenerateBinaryNameFromPath("portmaster-core_v0-1-2.exe")) + assert.Equal(t, "Win Store App", GenerateBinaryNameFromPath("WinStore.App.exe")) + assert.Equal(t, "Test Script", GenerateBinaryNameFromPath(".test-script")) + assert.Equal(t, "Browser Broker", GenerateBinaryNameFromPath("browser_broker.exe")) +} + +func TestCleanFileDescription(t *testing.T) { + assert.Equal(t, "Product Name", cleanFileDescription(strings.Fields("Product Name. Does this and that."))) + assert.Equal(t, "Product Name", cleanFileDescription(strings.Fields("Product Name - Does this and that."))) + assert.Equal(t, "Product Name", cleanFileDescription(strings.Fields("Product Name / Does this and that."))) + assert.Equal(t, "Product Name", cleanFileDescription(strings.Fields("Product Name :: Does this and that."))) + assert.Equal(t, "/ Product Name", cleanFileDescription(strings.Fields("/ Product Name"))) + assert.Equal(t, "Product", cleanFileDescription(strings.Fields("Product / Name"))) + + assert.Equal(t, + "Product Name a Does this and that.", + cleanFileDescription(strings.Fields("Product Name a Does this and that.")), + ) +} diff --git a/utils/osdetail/binmeta_windows.go b/utils/osdetail/binmeta_windows.go new file mode 100644 index 0000000..6a7bb6b --- /dev/null +++ b/utils/osdetail/binmeta_windows.go @@ -0,0 +1,96 @@ +package osdetail + +import ( + "bufio" + "bytes" + "fmt" + "strings" +) + +const powershellGetFileDescription = `Get-ItemProperty "%s" | Format-List` + +// GetBinaryNameFromSystem queries the operating system for a human readable +// name for the given binary path. +func GetBinaryNameFromSystem(path string) (string, error) { + // Get FileProperties via Powershell call. + output, err := runPowershellCmd(fmt.Sprintf(powershellGetFileDescription, path)) + if err != nil { + return "", fmt.Errorf("failed to get file properties of %s: %s", path, err) + } + + // Create scanner for the output. + scanner := bufio.NewScanner(bytes.NewBufferString(output)) + scanner.Split(bufio.ScanLines) + + // Search for the FileDescription line. + for scanner.Scan() { + // Split line up into fields. + fields := strings.Fields(scanner.Text()) + // Discard lines with less than two fields. + if len(fields) < 2 { + continue + } + // Skip all lines that we aren't looking for. + if fields[0] != "FileDescription:" { + continue + } + + // Clean and return. + return cleanFileDescription(fields[1:]), nil + } + + // Generate a default name as default. + return "", ErrNotFound +} + +const powershellGetIcon = `Add-Type -AssemblyName System.Drawing +$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon("%s") +$MemoryStream = New-Object System.IO.MemoryStream +$Icon.save($MemoryStream) +$Bytes = $MemoryStream.ToArray() +$MemoryStream.Flush() +$MemoryStream.Dispose() +[convert]::ToBase64String($Bytes)` + +// TODO: This returns a small and crappy icon. + +// Saving a better icon to file works: +/* +Add-Type -AssemblyName System.Drawing +$ImgList = New-Object System.Windows.Forms.ImageList +$ImgList.ImageSize = New-Object System.Drawing.Size(256,256) +$ImgList.ColorDepth = 32 +$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon("C:\Program Files (x86)\Mozilla Firefox\firefox.exe") +$ImgList.Images.Add($Icon); +$BigIcon = $ImgList.Images.Item(0) +$BigIcon.Save("test.png") +*/ + +// But not saving to a memory stream: +/* +Add-Type -AssemblyName System.Drawing +$ImgList = New-Object System.Windows.Forms.ImageList +$ImgList.ImageSize = New-Object System.Drawing.Size(256,256) +$ImgList.ColorDepth = 32 +$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon("C:\Program Files (x86)\Mozilla Firefox\firefox.exe") +$ImgList.Images.Add($Icon); +$MemoryStream = New-Object System.IO.MemoryStream +$BigIcon = $ImgList.Images.Item(0) +$BigIcon.Save($MemoryStream) +$Bytes = $MemoryStream.ToArray() +$MemoryStream.Flush() +$MemoryStream.Dispose() +[convert]::ToBase64String($Bytes) +*/ + +// GetBinaryIconFromSystem queries the operating system for the associated icon +// for a given binary path. +func GetBinaryIconFromSystem(path string) (string, error) { + // Get Associated File Icon via Powershell call. + output, err := runPowershellCmd(fmt.Sprintf(powershellGetIcon, path)) + if err != nil { + return "", fmt.Errorf("failed to get file properties of %s: %s", path, err) + } + + return "data:image/png;base64," + output, nil +} diff --git a/utils/osdetail/errors.go b/utils/osdetail/errors.go new file mode 100644 index 0000000..367e922 --- /dev/null +++ b/utils/osdetail/errors.go @@ -0,0 +1,9 @@ +package osdetail + +import "errors" + +var ( + ErrNotSupported = errors.New("not supported") + ErrNotFound = errors.New("not found") + ErrEmptyOutput = errors.New("command succeeded with empty output") +) diff --git a/utils/osdetail/powershell_windows.go b/utils/osdetail/powershell_windows.go new file mode 100644 index 0000000..a7d63a4 --- /dev/null +++ b/utils/osdetail/powershell_windows.go @@ -0,0 +1,47 @@ +package osdetail + +import ( + "bytes" + "errors" + "os/exec" + "strings" +) + +func runPowershellCmd(script string) (output string, err error) { + // Create command to execute. + cmd := exec.Command( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + script, + ) + + // Create and assign output buffers. + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + // Run command and collect output. + err = cmd.Run() + stdout, stderr := stdoutBuf.String(), stderrBuf.String() + if err != nil { + return "", err + } + // Powershell might not return an error, but just write to stdout instead. + if stderr != "" { + return "", errors.New(strings.SplitN(stderr, "\n", 2)[0]) + } + + // Debugging output: + // fmt.Printf("powershell stdout: %s\n", stdout) + // fmt.Printf("powershell stderr: %s\n", stderr) + + // Finalize stdout. + cleanedOutput := strings.TrimSpace(stdout) + if cleanedOutput == "" { + return "", ErrEmptyOutput + } + + return cleanedOutput, nil +} diff --git a/utils/osdetail/test/main_windows.go b/utils/osdetail/test/main_windows.go index c4247bd..1d46950 100644 --- a/utils/osdetail/test/main_windows.go +++ b/utils/osdetail/test/main_windows.go @@ -7,9 +7,42 @@ import ( ) func main() { + fmt.Println("Binary Names:") + printBinaryName("openvpn-gui.exe", `C:\Program Files\OpenVPN\bin\openvpn-gui.exe`) + printBinaryName("firefox.exe", `C:\Program Files (x86)\Mozilla Firefox\firefox.exe`) + printBinaryName("powershell.exe", `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`) + printBinaryName("explorer.exe", `C:\Windows\explorer.exe`) + printBinaryName("svchost.exe", `C:\Windows\System32\svchost.exe`) + + fmt.Println("\n\nBinary Icons:") + printBinaryIcon("openvpn-gui.exe", `C:\Program Files\OpenVPN\bin\openvpn-gui.exe`) + printBinaryIcon("firefox.exe", `C:\Program Files (x86)\Mozilla Firefox\firefox.exe`) + printBinaryIcon("powershell.exe", `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`) + printBinaryIcon("explorer.exe", `C:\Windows\explorer.exe`) + printBinaryIcon("svchost.exe", `C:\Windows\System32\svchost.exe`) + + fmt.Println("\n\nSvcHost Service Names:") names, err := osdetail.GetAllServiceNames() if err != nil { panic(err) } fmt.Printf("%+v\n", names) } + +func printBinaryName(name, path string) { + binName, err := osdetail.GetBinaryName(path) + if err != nil { + fmt.Printf("%s: ERROR: %s\n", name, err) + } else { + fmt.Printf("%s: %s\n", name, binName) + } +} + +func printBinaryIcon(name, path string) { + binIcon, err := osdetail.GetBinaryIcon(path) + if err != nil { + fmt.Printf("%s: ERROR: %s\n", name, err) + } else { + fmt.Printf("%s: %s\n", name, binIcon) + } +} From 3b2b4c052beeccdeecc2aa96459bbfdde953e452 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 17 Nov 2020 10:04:32 +0100 Subject: [PATCH 2/2] Implement review suggestions --- utils/osdetail/binmeta_windows.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/osdetail/binmeta_windows.go b/utils/osdetail/binmeta_windows.go index 6a7bb6b..01650f4 100644 --- a/utils/osdetail/binmeta_windows.go +++ b/utils/osdetail/binmeta_windows.go @@ -7,7 +7,7 @@ import ( "strings" ) -const powershellGetFileDescription = `Get-ItemProperty "%s" | Format-List` +const powershellGetFileDescription = `Get-ItemProperty %q | Format-List` // GetBinaryNameFromSystem queries the operating system for a human readable // name for the given binary path. @@ -44,7 +44,7 @@ func GetBinaryNameFromSystem(path string) (string, error) { } const powershellGetIcon = `Add-Type -AssemblyName System.Drawing -$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon("%s") +$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon(%q) $MemoryStream = New-Object System.IO.MemoryStream $Icon.save($MemoryStream) $Bytes = $MemoryStream.ToArray() @@ -84,7 +84,7 @@ $MemoryStream.Dispose() */ // GetBinaryIconFromSystem queries the operating system for the associated icon -// for a given binary path. +// for a given binary path and returns it as a data-URL. func GetBinaryIconFromSystem(path string) (string, error) { // Get Associated File Icon via Powershell call. output, err := runPowershellCmd(fmt.Sprintf(powershellGetIcon, path))