Skip to content

BED-6446: Add winres tool to generate Windows Resources#169

Open
StranDutton wants to merge 6 commits intomainfrom
BED-6446-include-version-in-exe-props
Open

BED-6446: Add winres tool to generate Windows Resources#169
StranDutton wants to merge 6 commits intomainfrom
BED-6446-include-version-in-exe-props

Conversation

@StranDutton
Copy link
Contributor

@StranDutton StranDutton commented Feb 23, 2026

Ticket

https://specterops.atlassian.net/browse/BED-6446

Summary

  • Added go-winres in order to apply manifest information to the .exe for Windows users.
  • Added new step in the build and publish GitHub actions in order to generate these resources just before the application is built and published.
  • Unit tests added for the script that generates the file that gets read by go-winres.
  • Use of the winres tool required a version bump from Go 1.23.0 -> 1.24.0 - I reran unit tests and performed a smoke test to make sure everything still works as expected after updating.

Testing

Testing Instructions

  • Goal: run the script I created to generate the Windows resources, build the image locally, and then check the .exe to make sure that file properties get populated.

    1. Install go-winres onto the windows machine (not in the AzH project directory)

      • go install http://github.com/tc-hib/go-winres@latest
    2. After go-winres is installed, pull my branch BED-6446-include-version-in-exe-props

    3. Then run go run cmd/generate-windows-resources/generate-windows-resources.go "v0.0.0-rolling+8675"

    • Ensure the .syso file is generated
    1. Build AzureHound (on Windows) with this command:

      $version = git describe --tags --exact-match 2>$null
      if (-not $version) { $version = git rev-parse HEAD }
      go build -ldflags "-s -w -X github.com/bloodhoundad/azurehound/v2/constants.Version=$version" .
    2. Find the .exe and inspect the properties to ensure everything looks correct.

    • Expected values:
    "CompanyName": "Created by the BloodHound Enterprise team - https://bloodhoundenterprise.io",
    "FileDescription": "The official tool for collecting Azure data for BloodHound and BloodHound Enterprise",
    "FileVersion": "0.0.0.0",
    "LegalCopyright": "Copyright (C) 2026 Specter Ops, Inc.",
    "OriginalFilename": "azurehound.exe",
    "ProductName": "AzureHound",
    "ProductVersion": "v0.0.0-rolling+8675"
    • Also, it should have a Hound icon included 🤞

Demo

All information populated as expected when examining the properties of the .exe ✅
image
image

//Used `act` to run GitHub Actions locally - output for Windows matrix:
[...]
| ✓ Windows resources generated successfully!
|   Product Version: v2.9.0
[Publish/build                  ]   ✅  Success - Main Generate Windows Resources [2.378763709s]
[Publish/build                  ] ⭐ Run Main Build
[Publish/build                  ]   🐳  docker exec cmd=[bash -e /var/run/act/workflow/3] user= workdir=
| go: downloading github.com/judwhite/go-svc v1.2.1
| go: downloading github.com/inconshreveable/mousetrap v1.1.0
| ✓ Windows resources generated successfully!
|   Product Version: v0.0.0-rolling+5f8807a4107f0b80debaf79b2d255bfa7078a54a
[Build/build                    ]   ✅  Success - Main Generate Windows Resources [2.705380417s]
[...]

Summary by CodeRabbit

  • New Features

    • Windows resource generation capability integrated into build process with support in both build and publish workflows
  • Chores

    • Go language version upgraded from 1.23.0 to 1.24.0
    • System and network dependencies updated to latest versions
    • New build-time tools and libraries added
    • Company metadata constant introduced for application identification

@coderabbitai
Copy link

coderabbitai bot commented Feb 23, 2026

Walkthrough

The PR introduces Windows resource generation functionality to the project. It adds a new Go CLI utility that generates Windows resource configuration and invokes the go-winres tool, includes new workflow steps to execute this utility during build and publish processes, adds a Company constant, and updates Go dependencies.

Changes

Cohort / File(s) Summary
GitHub Actions Workflows
.github/workflows/build.yml, .github/workflows/publish.yml
Each workflow adds a "Generate Windows Resources" step (Windows-only, continue-on-error) that executes the new CLI tool with a version string before the Build step.
Constants
constants/misc.go
Adds new exported constant Company with value "Specter Ops, Inc.".
Go Module Configuration
go.mod
Bumps Go version from 1.23.0 to 1.24.0 and toolchain to go1.24.2. Updates direct dependencies (golang.org/x/net, golang.org/x/sys). Adds indirect dependencies including github.com/tc-hib/go-winres, github.com/tc-hib/winres, and related CLI/image libraries. Adds go-winres tool directive.
Windows Resource Generation
winres/generate-windows-resources/generate-windows-resources.go, winres/generate-windows-resources/generate-windows-resources_test.go
New CLI tool parses product version, constructs winres JSON configuration with icon mapping and metadata (company, description, copyright), writes config to winres/winres.json, and invokes go-winres tool. Comprehensive tests validate version parsing, config structure, and metadata fields.

Sequence Diagram

sequenceDiagram
    participant WF as GitHub Workflow
    participant CLI as generate-windows-resources CLI
    participant FS as File System
    participant Tool as go-winres Tool

    WF->>CLI: Execute with version argument
    CLI->>CLI: Parse and validate product version
    CLI->>CLI: Build winres configuration map<br/>(icon paths, metadata, version info)
    CLI->>FS: Create winres directory
    CLI->>FS: Write winres.json with config
    CLI->>Tool: Execute go-winres make command
    Tool->>FS: Generate Windows resource files
    Tool-->>CLI: Return status
    CLI->>WF: Return success/failure
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hoppy hops through Windows resources so fine,
With metadata gleaming and version lines shine,
A CLI tool bundled, configs all neat,
Icons and versions—the build is complete!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'BED-6446: Add winres tool to generate Windows Resources' clearly and specifically describes the main objective of the pull request, matching the core change: introducing the winres tool to generate Windows resources for the project.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch BED-6446-include-version-in-exe-props

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@StranDutton
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link

coderabbitai bot commented Feb 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
cmd/generate-windows-resources/generate-windows-resources.go (2)

67-76: parseProductVersion reads from the global os.Args directly.

This works, but makes the function harder to test in isolation without mutating global state (which the tests do). Consider accepting []string as a parameter for better testability. That said, it's a small build utility so this is purely a style suggestion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/generate-windows-resources/generate-windows-resources.go` around lines 67
- 76, Change parseProductVersion to accept an argument slice (e.g., args
[]string) instead of reading os.Args directly so it becomes testable; update its
signature (parseProductVersion(args []string) (string, error)), replace uses of
os.Args within the function with the provided args slice (use args[0] for the
program name and args[1] for the version), and update any call sites (main or
tests) to pass os.Args or a custom slice when testing.

111-130: Deferred f.Close() error is silently discarded.

On buffered/networked filesystems, Close can be the call that actually flushes and reports a write error. Since Encode succeeds doesn't guarantee the data hit disk. Consider checking the close error explicitly:

Suggested fix
 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 {
+		f.Close()
 		return fmt.Errorf("failed to encode JSON: %w", err)
 	}
 
-	return nil
+	return f.Close()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmd/generate-windows-resources/generate-windows-resources.go` around lines
111 - 130, The deferred file Close in writeWinresConfig currently discards any
error from f.Close; capture and handle the Close error so flush/write failures
aren't ignored—update writeWinresConfig to check the error returned by f.Close
(in the defer or after encoder.Encode) and return or wrap that error (e.g., if
enc.Encode succeeded but f.Close returns non-nil, return fmt.Errorf("failed to
close %s: %w", configPath, err)). Ensure you reference the same file handle (f)
and configPath and propagate the Close error instead of silently dropping it.
.github/workflows/publish.yml (1)

35-40: Double error suppression: continue-on-error: true and || echo are redundant.

The continue-on-error: true on line 37 already ensures the job won't fail if this step errors. The || echo fallback on line 40 provides a friendlier log message but is technically unnecessary for preventing failure. Not a bug—just noting the overlap. Consider keeping only one mechanism for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/publish.yml around lines 35 - 40, The step named "Generate
Windows Resources" uses both continue-on-error: true and a shell fallback "||
echo 'Failed to generate Windows Resources. Skipping...'", which is redundant;
edit that step to remove one mechanism (preferably keep continue-on-error: true
and delete the "|| echo ..." suffix in the run block) so error suppression is
handled in one place and logs remain clean; look for the step name "Generate
Windows Resources" and the run command that invokes go run
cmd/generate-windows-resources/generate-windows-resources.go to make the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@go.mod`:
- Line 54: The go.mod currently pulls golang.org/x/image at an old vulnerable
revision; add an explicit module override to bump it to v0.18.0 or later
(recommended v0.36.0) to mitigate CVE-2024-24792 and ensure the indirect
consumer (go-winres) uses the fixed version; update go.mod by adding a
require/replace for golang.org/x/image with the chosen safe version and then run
go mod tidy to update the dependency graph and lockfile.

---

Nitpick comments:
In @.github/workflows/publish.yml:
- Around line 35-40: The step named "Generate Windows Resources" uses both
continue-on-error: true and a shell fallback "|| echo 'Failed to generate
Windows Resources. Skipping...'", which is redundant; edit that step to remove
one mechanism (preferably keep continue-on-error: true and delete the "|| echo
..." suffix in the run block) so error suppression is handled in one place and
logs remain clean; look for the step name "Generate Windows Resources" and the
run command that invokes go run
cmd/generate-windows-resources/generate-windows-resources.go to make the change.

In `@cmd/generate-windows-resources/generate-windows-resources.go`:
- Around line 67-76: Change parseProductVersion to accept an argument slice
(e.g., args []string) instead of reading os.Args directly so it becomes
testable; update its signature (parseProductVersion(args []string) (string,
error)), replace uses of os.Args within the function with the provided args
slice (use args[0] for the program name and args[1] for the version), and update
any call sites (main or tests) to pass os.Args or a custom slice when testing.
- Around line 111-130: The deferred file Close in writeWinresConfig currently
discards any error from f.Close; capture and handle the Close error so
flush/write failures aren't ignored—update writeWinresConfig to check the error
returned by f.Close (in the defer or after encoder.Encode) and return or wrap
that error (e.g., if enc.Encode succeeded but f.Close returns non-nil, return
fmt.Errorf("failed to close %s: %w", configPath, err)). Ensure you reference the
same file handle (f) and configPath and propagate the Close error instead of
silently dropping it.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 58c240d and d8d38a3.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • winres/favicon.ico is excluded by !**/*.ico
📒 Files selected for processing (6)
  • .github/workflows/build.yml
  • .github/workflows/publish.yml
  • cmd/generate-windows-resources/generate-windows-resources.go
  • cmd/generate-windows-resources/generate-windows-resources_test.go
  • constants/misc.go
  • go.mod

@StranDutton StranDutton marked this pull request as ready for review February 24, 2026 21:11
@StranDutton StranDutton changed the title BED-6446: Add winres tool to generate windows resources, tests BED-6446: Add winres tool to generate Windows Resources Feb 24, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
winres/generate-windows-resources/generate-windows-resources.go (2)

99-99: Inject time source for deterministic metadata generation.

Using time.Now() inline makes output time-dependent and can cause edge-time test flakes (e.g., New Year rollover).

⏱️ Proposed refactor
 const (
@@
 	fileVersion    = "0.0.0.0" // Windows PE file version; we will update 'productVersion' field instead of this one
 )
+
+var now = time.Now
@@
-							"LegalCopyright":   fmt.Sprintf("Copyright (C) %d %s", time.Now().Year(), constants.Company),
+							"LegalCopyright":   fmt.Sprintf("Copyright (C) %d %s", now().Year(), constants.Company),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@winres/generate-windows-resources/generate-windows-resources.go` at line 99,
The code uses time.Now() inline when building the "LegalCopyright" string
causing non-deterministic output; refactor the function that constructs the
metadata (the function that sets "LegalCopyright" in
generate-windows-resources.go) to accept an injected time source or explicit
year parameter (e.g., a time.Time or int year) instead of calling time.Now()
inside the function, and replace fmt.Sprintf("Copyright (C) %d %s",
time.Now().Year(), constants.Company) with a format that uses the injected value
so tests can pass a fixed year for deterministic metadata generation.

47-47: Decouple argument parsing from os.Args and normalize input.

parseProductVersion currently depends on process-global state and accepts whitespace-only values. Passing args explicitly and trimming keeps behavior deterministic and test-friendly.

♻️ Proposed refactor
 import (
 	"encoding/json"
 	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strings"
 	"time"
@@
 func run() error {
-	productVersion, err := parseProductVersion()
+	productVersion, err := parseProductVersion(os.Args)
 	if err != nil {
 		return err
 	}
@@
-func parseProductVersion() (string, error) {
-	if len(os.Args) < 2 {
-		return "", fmt.Errorf("usage: %s <product-version>", filepath.Base(os.Args[0]))
+func parseProductVersion(args []string) (string, error) {
+	if len(args) < 2 {
+		return "", fmt.Errorf("usage: %s <product-version>", filepath.Base(args[0]))
 	}
-	version := os.Args[1]
+	version := strings.TrimSpace(args[1])
 	if version == "" {
 		return "", fmt.Errorf("product version cannot be empty")
 	}
 	return version, nil
 }

Also applies to: 67-75

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@winres/generate-windows-resources/generate-windows-resources.go` at line 47,
Change parseProductVersion to stop reading os.Args directly: give it an explicit
input (e.g., func parseProductVersion(raw string) (string, error) or func
parseProductVersion(args []string) ...) and trim the received string
(strings.TrimSpace) before validation; if the trimmed value is empty return a
descriptive error instead of accepting whitespace-only input. Update the call
site that does productVersion, err := parseProductVersion() to pass the
appropriate argument (the specific CLI token or os.Args element) and apply the
same refactor+trim/validation to the other parsing helpers in this file so all
argument parsing is deterministic and testable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@winres/generate-windows-resources/generate-windows-resources.go`:
- Around line 111-127: The writeWinresConfig function currently defers f.Close()
without checking its error; update it to capture and handle Close() failures
(after encoding with enc.Encode) by replacing the simple defer f.Close() with a
deferred closure that checks the error returned by f.Close() and wraps/returns
it (or appends it to any existing error from enc.Encode) so the function fails
if the file close/fsync fails; reference the writeWinresConfig function, the
file handle f, configPath and the enc.Encode call when implementing this change.

---

Nitpick comments:
In `@winres/generate-windows-resources/generate-windows-resources.go`:
- Line 99: The code uses time.Now() inline when building the "LegalCopyright"
string causing non-deterministic output; refactor the function that constructs
the metadata (the function that sets "LegalCopyright" in
generate-windows-resources.go) to accept an injected time source or explicit
year parameter (e.g., a time.Time or int year) instead of calling time.Now()
inside the function, and replace fmt.Sprintf("Copyright (C) %d %s",
time.Now().Year(), constants.Company) with a format that uses the injected value
so tests can pass a fixed year for deterministic metadata generation.
- Line 47: Change parseProductVersion to stop reading os.Args directly: give it
an explicit input (e.g., func parseProductVersion(raw string) (string, error) or
func parseProductVersion(args []string) ...) and trim the received string
(strings.TrimSpace) before validation; if the trimmed value is empty return a
descriptive error instead of accepting whitespace-only input. Update the call
site that does productVersion, err := parseProductVersion() to pass the
appropriate argument (the specific CLI token or os.Args element) and apply the
same refactor+trim/validation to the other parsing helpers in this file so all
argument parsing is deterministic and testable.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 23d799f and 9ddd023.

📒 Files selected for processing (4)
  • .github/workflows/build.yml
  • .github/workflows/publish.yml
  • winres/generate-windows-resources/generate-windows-resources.go
  • winres/generate-windows-resources/generate-windows-resources_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • .github/workflows/build.yml
  • .github/workflows/publish.yml

Comment on lines +111 to +127
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle Close() errors when writing winres.json.

Ignoring Close failures can return success even when the file write is incomplete (Line 121).

🛠️ Proposed fix
-func writeWinresConfig(config map[string]interface{}) error {
+func writeWinresConfig(config map[string]interface{}) (err error) {
 	if err := os.MkdirAll(winresDir, 0755); err != nil {
 		return fmt.Errorf("failed to create winres directory: %w", err)
 	}
@@
-	f, err := os.Create(configPath)
+	f, err := os.Create(configPath)
 	if err != nil {
 		return fmt.Errorf("failed to create %s: %w", configPath, err)
 	}
-	defer f.Close()
+	defer func() {
+		if cerr := f.Close(); err == nil && cerr != nil {
+			err = fmt.Errorf("failed to close %s: %w", configPath, cerr)
+		}
+	}()
@@
 	if err := enc.Encode(config); err != nil {
 		return fmt.Errorf("failed to encode JSON: %w", err)
 	}
 
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@winres/generate-windows-resources/generate-windows-resources.go` around lines
111 - 127, The writeWinresConfig function currently defers f.Close() without
checking its error; update it to capture and handle Close() failures (after
encoding with enc.Encode) by replacing the simple defer f.Close() with a
deferred closure that checks the error returned by f.Close() and wraps/returns
it (or appends it to any existing error from enc.Encode) so the function fails
if the file close/fsync fails; reference the writeWinresConfig function, the
file handle f, configPath and the enc.Encode call when implementing this change.

@StranDutton StranDutton self-assigned this Feb 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants