Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,21 @@ Flags:
-h, --help help for azurehound
--json Output logs as json
-j, --jwt string Use an acquired JWT to authenticate into Azure
--log-compress Compress rotated logs with gzip (default: true)
--log-file string Output logs to this file
--log-max-age int Maximum age in days for rotated logs (default: 14; 0 disables age pruning)
--log-max-backups int Maximum number of rotated logs to retain (default: 20; 0 disables count pruning)
--log-max-size int Maximum active log size in MiB before rotation (default: 100)
--proxy string Sets the proxy URL for the AzureHound service
-r, --refresh-token string Use an acquired refresh token to authenticate into Azure
-v, --verbosity int AzureHound verbosity level (defaults to 0) [Min: -1, Max: 2]
--version version for azurehound

Use "azurehound [command] --help" for more information about a command.
```

### Log file management

When `--log-file` is configured, AzureHound rotates the active log when it reaches `--log-max-size`. Rotated logs are timestamped, stored beside the active log, and compressed with gzip by default.

Archives are retained for at most `--log-max-age` days and are also limited by `--log-max-backups`. Setting either retention option to `0` disables that individual limit. The defaults retain no more than 20 archives or 14 days of history. Only one AzureHound process should write to a given log file.
70 changes: 54 additions & 16 deletions cmd/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"time"

"github.com/bloodhoundad/azurehound/v2/config"
Expand Down Expand Up @@ -79,9 +80,9 @@ func configure() error {
// Configure Azure connection
if _, region, err := choose("Azure Region", config.AzRegions, 1); err != nil {
return err
} else if tenantId, err := prompt("Directory (tenant) ID", validateGuid, false); err != nil {
} else if tenantId, err := prompt("Directory (tenant) ID", validateGuid, false, ""); err != nil {
return err
} else if appId, err := prompt("Application (client) ID", validateGuid, false); err != nil {
} else if appId, err := prompt("Application (client) ID", validateGuid, false, ""); err != nil {
return err
} else if _, authMethod, err := choose("Authentication Method", enums.AuthMethods(), 0); err != nil {
return err
Expand All @@ -92,28 +93,28 @@ func configure() error {

if authMethod == enums.Certificate {
if genCert = confirm("Generate Certificate and Key", true); genCert {
if keyPass, err := prompt("Private Key Passphrase (optional)", nil, true); err != nil {
if keyPass, err := prompt("Private Key Passphrase (optional)", nil, true, ""); err != nil {
return err
} else {
config.AzCert.Set(genCertPath)
config.AzKey.Set(genKeyPath)
config.AzKeyPass.Set(keyPass)
}
} else if certPath, err := prompt("Public Certificate Path", validatePem, false); err != nil {
} else if certPath, err := prompt("Public Certificate Path", validatePem, false, ""); err != nil {
return err
} else if keyPath, err := prompt("Private Key Path", validatePem, false); err != nil {
} else if keyPath, err := prompt("Private Key Path", validatePem, false, ""); err != nil {
return err
} else if keyPass, err := prompt("Private Key Passphrase (optional)", nil, true); err != nil {
} else if keyPass, err := prompt("Private Key Passphrase (optional)", nil, true, ""); err != nil {
return err
} else {
config.AzCert.Set(certPath)
config.AzKey.Set(keyPath)
config.AzKeyPass.Set(keyPass)
}
} else if authMethod == enums.UsernamePassword {
if upn, err := prompt("Input the User Principal Name", validateUserPrincipalName, false); err != nil {
if upn, err := prompt("Input the User Principal Name", validateUserPrincipalName, false, ""); err != nil {
return err
} else if password, err := prompt("Input the password", nil, true); err != nil {
} else if password, err := prompt("Input the password", nil, true, ""); err != nil {
return err
} else {
config.AzUsername.Set(upn)
Expand All @@ -125,7 +126,7 @@ func configure() error {
return err
} else if identityType == "User-Assigned" {
// User-Assigned: Prompt for Client ID
if umiClient, err := prompt("Input the User-Assigned Managed Identity (Client ID)", validateGuid, true); err != nil {
if umiClient, err := prompt("Input the User-Assigned Managed Identity (Client ID)", validateGuid, true, ""); err != nil {
return err
} else {
config.AzManagedIdentityClientId.Set(umiClient)
Expand All @@ -134,7 +135,7 @@ func configure() error {
// System-Assigned: Set client ID to empty string
config.AzManagedIdentityClientId.Set("")
}
} else if secret, err := prompt("Client Secret", nil, true); err != nil {
} else if secret, err := prompt("Client Secret", nil, true, ""); err != nil {
return err
} else {
config.AzSecret.Set(secret)
Expand All @@ -144,11 +145,11 @@ func configure() error {

// Configure BloodHound Enterprise Connection
if confirm("Setup connection to BloodHound Enterprise", true) {
if bheUrl, err := prompt("BloodHound Enterprise URL", config.ValidateURL, false); err != nil {
if bheUrl, err := prompt("BloodHound Enterprise URL", config.ValidateURL, false, ""); err != nil {
return err
} else if bheTokenId, err := prompt("BloodHound Enterprise Token ID", validateGuid, false); err != nil {
} else if bheTokenId, err := prompt("BloodHound Enterprise Token ID", validateGuid, false, ""); err != nil {
return err
} else if bheToken, err := prompt("BloodHound Enterprise Token", nil, true); err != nil {
} else if bheToken, err := prompt("BloodHound Enterprise Token", nil, true, ""); err != nil {
return err
} else {
config.BHEUrl.Set(bheUrl)
Expand All @@ -159,7 +160,7 @@ func configure() error {

// Configure Proxy
if confirm("Set proxy URL", true) {
if proxyURL, err := prompt("Proxy URL", config.ValidateURL, false); err != nil {
if proxyURL, err := prompt("Proxy URL", config.ValidateURL, false, ""); err != nil {
return err
} else {
if parsedURL, err := url.Parse(proxyURL); err != nil {
Expand All @@ -178,11 +179,34 @@ func configure() error {
if confirm("Setup AzureHound logging", true) {
if idx, _, err := choose("Verbosity", verbosityOptions, 1); err != nil {
return err
} else if logFile, err := prompt("Log file (optional)", nil, false); err != nil {
} else if logFile, err := prompt("Log file (optional)", nil, false, ""); err != nil {
return err
} else if logMaxSize, err := prompt("Maximum log size in MiB", validateMinInt(1), false, strconv.Itoa(config.DefaultLogMaxSize)); err != nil {
return err
} else if logMaxAge, err := prompt("Maximum archive age in days (0 disables)", validateMinInt(0), false, strconv.Itoa(config.DefaultLogMaxAge)); err != nil {
return err
} else if logMaxBackups, err := prompt("Maximum archive count (0 disables)", validateMinInt(0), false, strconv.Itoa(config.DefaultLogMaxBackups)); err != nil {
return err
} else {
logMaxSizeValue, err := strconv.Atoi(logMaxSize)
if err != nil {
return err
}
logMaxAgeValue, err := strconv.Atoi(logMaxAge)
if err != nil {
return err
}
logMaxBackupsValue, err := strconv.Atoi(logMaxBackups)
if err != nil {
return err
}

config.VerbosityLevel.Set(idx - 1)
config.LogFile.Set(logFile)
config.LogMaxSize.Set(logMaxSizeValue)
config.LogMaxAge.Set(logMaxAgeValue)
config.LogMaxBackups.Set(logMaxBackupsValue)
config.LogCompress.Set(confirm("Compress rotated logs", true))
config.JsonLogs.Set(confirm("Enable Structured Logs", false))
}
}
Expand Down Expand Up @@ -213,10 +237,11 @@ func configure() error {
return nil
}

func prompt(label string, validator func(string) error, isSensitive bool) (string, error) {
func prompt(label string, validator func(string) error, isSensitive bool, defaultValue string) (string, error) {
p := promptui.Prompt{
Label: label,
Validate: validator,
Default: defaultValue,
}
if isSensitive {
p.HideEntered = true
Expand All @@ -225,6 +250,19 @@ func prompt(label string, validator func(string) error, isSensitive bool) (strin
return p.Run()
}

func validateMinInt(minimum int) func(string) error {
return func(input string) error {
value, err := strconv.Atoi(input)
if err != nil {
return fmt.Errorf("must be an integer")
}
if value < minimum {
return fmt.Errorf("must be at least %d", minimum)
}
return nil
}
}

func choose(label string, items []string, pos int) (int, string, error) {
s := promptui.Select{
Label: label,
Expand Down
40 changes: 40 additions & 0 deletions cmd/configure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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.

package cmd

import "testing"

func TestValidateMinInt(t *testing.T) {
tests := []struct {
name string
minimum int
input string
wantErr bool
}{
{name: "minimum", minimum: 1, input: "1"},
{name: "above minimum", minimum: 1, input: "100"},
{name: "zero allowed", minimum: 0, input: "0"},
{name: "below minimum", minimum: 1, input: "0", wantErr: true},
{name: "negative", minimum: 0, input: "-1", wantErr: true},
{name: "not an integer", minimum: 0, input: "one", wantErr: true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateMinInt(test.minimum)(test.input)
if test.wantErr && err == nil {
t.Fatal("expected validation error")
}
if !test.wantErr && err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
})
}
}
3 changes: 3 additions & 0 deletions cmd/svc_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type azurehoundSvc struct {
func (s *azurehoundSvc) Init(env svc.Environment) error {
config.LoadValues(nil, config.Options())
config.SetAzureDefaults()
if err := config.ValidateLoggingConfig(); err != nil {
return fmt.Errorf("invalid logging configuration: %w", err)
}

if logr, err := logger.GetLogger(); err != nil {
return err
Expand Down
3 changes: 3 additions & 0 deletions cmd/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error {

config.LoadValues(cmd, config.Options())
config.SetAzureDefaults()
if err := config.ValidateLoggingConfig(); err != nil {
return fmt.Errorf("invalid logging configuration: %w", err)
}

if logr, err := logger.GetLogger(); err != nil {
return err
Expand Down
41 changes: 41 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ func SystemConfigDirs() []string {

const EnvPrefix string = "AZUREHOUND"

const (
DefaultLogMaxSize = 100
DefaultLogMaxAge = 14
DefaultLogMaxBackups = 20
)

var AzRegions = []string{
constants.China,
constants.Cloud,
Expand Down Expand Up @@ -118,6 +124,37 @@ var (
Persistent: true,
Default: "",
}
LogMaxSize = Config{
Name: "log-max-size",
Shorthand: "",
Usage: fmt.Sprintf("Maximum active log size in MiB before rotation (default: %d)", DefaultLogMaxSize),
Persistent: true,
Default: DefaultLogMaxSize,
MinValue: 1,
}
LogMaxAge = Config{
Name: "log-max-age",
Shorthand: "",
Usage: fmt.Sprintf("Maximum age in days for rotated logs (default: %d; 0 disables age pruning)", DefaultLogMaxAge),
Persistent: true,
Default: DefaultLogMaxAge,
MinValue: 0,
}
LogMaxBackups = Config{
Name: "log-max-backups",
Shorthand: "",
Usage: fmt.Sprintf("Maximum number of rotated logs to retain (default: %d; 0 disables count pruning)", DefaultLogMaxBackups),
Persistent: true,
Default: DefaultLogMaxBackups,
MinValue: 0,
}
LogCompress = Config{
Name: "log-compress",
Shorthand: "",
Usage: "Compress rotated logs with gzip (default: true)",
Persistent: true,
Default: true,
}
Proxy = Config{
Name: "proxy",
Shorthand: "",
Expand Down Expand Up @@ -368,6 +405,10 @@ var (
JsonLogs,
JWT,
LogFile,
LogMaxSize,
LogMaxAge,
LogMaxBackups,
LogCompress,
Proxy,
RefreshToken,
Pprof,
Expand Down
31 changes: 31 additions & 0 deletions config/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package config
import (
"fmt"
"net/url"
"os"

client "github.com/bloodhoundad/azurehound/v2/client/config"
config "github.com/bloodhoundad/azurehound/v2/config/internal"
Expand Down Expand Up @@ -57,6 +58,36 @@ func CheckCollectionConfigSanity(log logr.Logger) {
useSaneIntValues(ColStreamCount, log)
}

// ValidateLoggingConfig checks file logging settings before the logger is
// created. Logging limits are ignored when file logging is disabled.
func ValidateLoggingConfig() error {
if logFile, ok := LogFile.Value().(string); !ok || logFile == "" {
return nil
} else if fileInfo, err := os.Stat(logFile); err == nil && fileInfo.IsDir() {
return fmt.Errorf("%s must reference a file, not a directory: %q", LogFile.Name, logFile)
} else if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("could not inspect %s %q: %w", LogFile.Name, logFile, err)
} else if err == nil && fileInfo.Mode().IsRegular() {
if file, err := os.OpenFile(logFile, os.O_APPEND|os.O_WRONLY, 0); err != nil {
return fmt.Errorf("could not open %s %q for writing: %w", LogFile.Name, logFile, err)
} else if err := file.Close(); err != nil {
return fmt.Errorf("could not close %s %q after validating write access: %w", LogFile.Name, logFile, err)
}
}

if value := LogMaxSize.Value().(int); value < LogMaxSize.MinValue {
return fmt.Errorf("%s must be at least %d", LogMaxSize.Name, LogMaxSize.MinValue)
}
if value := LogMaxAge.Value().(int); value < LogMaxAge.MinValue {
return fmt.Errorf("%s must be at least %d", LogMaxAge.Name, LogMaxAge.MinValue)
}
if value := LogMaxBackups.Value().(int); value < LogMaxBackups.MinValue {
return fmt.Errorf("%s must be at least %d", LogMaxBackups.Name, LogMaxBackups.MinValue)
}

return nil
}

func useSaneIntValues(c config.Config, log logr.Logger) {
val := c.Value().(int)
if val < c.MinValue {
Expand Down
Loading
Loading