Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ jobs:
check-latest: true
cache: true

- name: Generate Windows Resources
if: matrix.os == 'windows'
continue-on-error: true
run: |
echo "Generating Windows resources..."
go run cmd/generate-windows-resources/generate-windows-resources.go "v0.0.0-rolling+${{ github.sha }}"

- name: Build
run: 'go build -ldflags="-s -w -X github.com/bloodhoundad/azurehound/v2/constants.Version=v0.0.0-rolling+${{ github.sha }}"'
env:
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ jobs:
check-latest: true
cache: true

- name: Generate Windows Resources
if: matrix.os == 'windows'
continue-on-error: true
run: |
echo "Generating Windows resources..."
go run cmd/generate-windows-resources/generate-windows-resources.go "${{ env.AZUREHOUND_VERSION }}"

- name: Build
run: 'go build -ldflags="-s -w -X github.com/bloodhoundad/azurehound/v2/constants.Version=${{ env.AZUREHOUND_VERSION }}"'
env:
Expand Down
142 changes: 142 additions & 0 deletions cmd/generate-windows-resources/generate-windows-resources.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (C) 2026 Specter Ops, Inc.
//
// This file is part of AzureHound.
//
// AzureHound is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// AzureHound is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package main

import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"

"github.com/bloodhoundad/azurehound/v2/constants"
)

const (
winresDir = "winres"
winresJSONFile = "winres.json"
iconFile = "favicon.ico"
langCodeUSEn = "0409" // US English
fileVersion = "0.0.0.0" // Windows PE file version; we will update 'productVersion' field instead of this one
)

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}

func run() error {
productVersion, err := parseProductVersion()
if err != nil {
return err
}

config := buildWinresConfig(productVersion)

if err := writeWinresConfig(config); err != nil {
return fmt.Errorf("failed to write winres config: %w", err)
}

if err := runWinres(); err != nil {
return fmt.Errorf("failed to generate windows resources: %w", err)
}

fmt.Printf("✓ Windows resources generated successfully!\n")
fmt.Printf(" Product Version: %s\n", productVersion)
return nil
}

func parseProductVersion() (string, error) {
if len(os.Args) < 2 {
return "", fmt.Errorf("usage: %s <product-version>", filepath.Base(os.Args[0]))
}
version := os.Args[1]
if version == "" {
return "", fmt.Errorf("product version cannot be empty")
}
return version, nil
}

func buildWinresConfig(productVersion string) map[string]interface{} {
return map[string]interface{}{
// Icon resource
"RT_GROUP_ICON": map[string]interface{}{
"APP": map[string]interface{}{
"0000": iconFile,
},
},
// Version information
"RT_VERSION": map[string]interface{}{
"#1": map[string]interface{}{
"0000": map[string]interface{}{
"fixed": map[string]interface{}{
"file_version": fileVersion,
"product_version": fileVersion,
},
"info": map[string]interface{}{
langCodeUSEn: map[string]string{
"FileDescription": constants.Description,
"ProductName": constants.DisplayName,
"CompanyName": constants.AuthorRef,
"LegalCopyright": fmt.Sprintf("Copyright (C) %d %s", time.Now().Year(), constants.Company),
"ProductVersion": productVersion,
"FileVersion": fileVersion,
"OriginalFilename": "azurehound.exe",
},
},
},
},
},
}
}

func writeWinresConfig(config map[string]interface{}) error {
if err := os.MkdirAll(winresDir, 0755); err != nil {
return fmt.Errorf("failed to create winres directory: %w", err)
}

configPath := filepath.Join(winresDir, winresJSONFile)
f, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create %s: %w", configPath, err)
}
defer f.Close()

enc := json.NewEncoder(f)
enc.SetIndent("", " ")
if err := enc.Encode(config); err != nil {
return fmt.Errorf("failed to encode JSON: %w", err)
}

return nil
}

func runWinres() error {
cmd := exec.Command("go", "tool", "go-winres", "make")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
return fmt.Errorf("go-winres command failed: %w", err)
}

return nil
}
171 changes: 171 additions & 0 deletions cmd/generate-windows-resources/generate-windows-resources_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Copyright (C) 2026 Specter Ops, Inc.
//
// This file is part of AzureHound.
//
// AzureHound is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// AzureHound is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package main

import (
"fmt"
"os"
"testing"
"time"

"github.com/bloodhoundad/azurehound/v2/constants"
)

func TestParseProductVersion(t *testing.T) {
tests := []struct {
name string
args []string
want string
wantErr bool
}{
{
name: "valid version",
args: []string{"cmd", "v1.2.3"},
want: "v1.2.3",
wantErr: false,
},
{
name: "valid version with build metadata",
args: []string{"cmd", "v0.0.0-rolling+5f8807a4107f0b80debaf79b2d245bfa7078a54b"},
want: "v0.0.0-rolling+5f8807a4107f0b80debaf79b2d245bfa7078a54b",
wantErr: false,
},
{
name: "no version provided",
args: []string{"cmd"},
want: "",
wantErr: true,
},
{
name: "empty version string",
args: []string{"cmd", ""},
want: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Save original os.Args and restore after test
oldArgs := os.Args
defer func() { os.Args = oldArgs }()

os.Args = tt.args

got, err := parseProductVersion()
if (err != nil) != tt.wantErr {
t.Errorf("parseProductVersion() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("parseProductVersion() = %v, want %v", got, tt.want)
}
})
}
}

func TestBuildWinresConfig(t *testing.T) {
productVersion := "v2.9.0-test"
config := buildWinresConfig(productVersion)

// Verify icon resource
rtGroupIcon, ok := config["RT_GROUP_ICON"].(map[string]interface{})
if !ok {
t.Fatal("RT_GROUP_ICON not found or wrong type")
}

app, ok := rtGroupIcon["APP"].(map[string]interface{})
if !ok {
t.Fatal("APP icon not found or wrong type")
}

iconPath, ok := app["0000"].(string)
if !ok {
t.Fatal("icon path not found or wrong type")
}

if iconPath != iconFile {
t.Errorf("icon path = %v, want %v", iconPath, iconFile)
}

// Verify top-level structure
rtVersion, ok := config["RT_VERSION"].(map[string]interface{})
if !ok {
t.Fatal("RT_VERSION not found or wrong type")
}

// Navigate to the version info
level1, ok := rtVersion["#1"].(map[string]interface{})
if !ok {
t.Fatal("#1 not found or wrong type")
}

level2, ok := level1["0000"].(map[string]interface{})
if !ok {
t.Fatal("0000 not found or wrong type")
}

// Test fixed section
fixed, ok := level2["fixed"].(map[string]interface{})
if !ok {
t.Fatal("fixed section not found")
}

if fixed["file_version"] != fileVersion {
t.Errorf("file_version = %v, want %v", fixed["file_version"], fileVersion)
}

if fixed["product_version"] != fileVersion {
t.Errorf("product_version = %v, want %v", fixed["product_version"], fileVersion)
}

// Test info section
info, ok := level2["info"].(map[string]interface{})
if !ok {
t.Fatal("info section not found")
}

langInfo, ok := info[langCodeUSEn].(map[string]string)
if !ok {
t.Fatal("language info not found")
}

// Verify all required fields
requiredFields := map[string]string{
"FileDescription": constants.Description,
"ProductName": constants.DisplayName,
"CompanyName": constants.AuthorRef,
"ProductVersion": productVersion,
"FileVersion": fileVersion,
"OriginalFilename": "azurehound.exe",
}

for field, expected := range requiredFields {
if got := langInfo[field]; got != expected {
t.Errorf("%s = %v, want %v", field, got, expected)
}
}

// Verify copyright contains current year and company
copyright := langInfo["LegalCopyright"]
currentYear := time.Now().Year()
expectedCopyright := fmt.Sprintf("Copyright (C) %d %s", currentYear, constants.Company)
if copyright != expectedCopyright {
t.Errorf("LegalCopyright = %v, want %v", copyright, expectedCopyright)
}
}
1 change: 1 addition & 0 deletions constants/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var Version string = "v0.0.0"
const (
Name string = "azurehound"
DisplayName string = "AzureHound"
Company string = "Specter Ops, Inc."
Description string = "The official tool for collecting Azure data for BloodHound and BloodHound Enterprise"
AuthorRef string = "Created by the BloodHound Enterprise team - https://bloodhoundenterprise.io"
AzPowerShellClientID string = "1950a258-227b-4e31-a9cf-717495945fc2"
Expand Down
Loading
Loading