diff --git a/README.md b/README.md index 5d916d33..6506b14d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,11 @@ 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] @@ -102,3 +106,9 @@ Flags: 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. diff --git a/cmd/configure.go b/cmd/configure.go index 908dfa00..eddd702e 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -34,6 +34,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "time" "github.com/bloodhoundad/azurehound/v2/config" @@ -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 @@ -92,18 +93,18 @@ 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) @@ -111,9 +112,9 @@ func configure() error { 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) @@ -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) @@ -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) @@ -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) @@ -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 { @@ -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)) } } @@ -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 @@ -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, diff --git a/cmd/configure_test.go b/cmd/configure_test.go new file mode 100644 index 00000000..18536ffc --- /dev/null +++ b/cmd/configure_test.go @@ -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) + } + }) + } +} diff --git a/cmd/svc_windows.go b/cmd/svc_windows.go index a3109f4a..6d134b7e 100644 --- a/cmd/svc_windows.go +++ b/cmd/svc_windows.go @@ -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 diff --git a/cmd/utils.go b/cmd/utils.go index 3dd88bcf..463ccedf 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -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 diff --git a/config/config.go b/config/config.go index eeedfaf9..a341add9 100644 --- a/config/config.go +++ b/config/config.go @@ -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, @@ -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: "", @@ -368,6 +405,10 @@ var ( JsonLogs, JWT, LogFile, + LogMaxSize, + LogMaxAge, + LogMaxBackups, + LogCompress, Proxy, RefreshToken, Pprof, diff --git a/config/utils.go b/config/utils.go index a7c36a1c..7a06291a 100644 --- a/config/utils.go +++ b/config/utils.go @@ -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" @@ -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 { diff --git a/config/utils_test.go b/config/utils_test.go index 9fafccc6..03e9562f 100644 --- a/config/utils_test.go +++ b/config/utils_test.go @@ -18,6 +18,7 @@ package config_test import ( + "strings" "testing" "github.com/bloodhoundad/azurehound/v2/config" @@ -73,3 +74,93 @@ func TestCheckCollectionConfigSanityOutOfBounds(t *testing.T) { } } } + +func TestValidateLoggingConfig(t *testing.T) { + oldLogFile := config.LogFile.Value() + oldMaxSize := config.LogMaxSize.Value() + oldMaxAge := config.LogMaxAge.Value() + oldMaxBackups := config.LogMaxBackups.Value() + defer func() { + config.LogFile.Set(oldLogFile) + config.LogMaxSize.Set(oldMaxSize) + config.LogMaxAge.Set(oldMaxAge) + config.LogMaxBackups.Set(oldMaxBackups) + }() + + config.LogFile.Set("azurehound.log") + config.LogMaxSize.Set(100) + config.LogMaxAge.Set(14) + config.LogMaxBackups.Set(20) + if err := config.ValidateLoggingConfig(); err != nil { + t.Fatalf("valid logging configuration returned an error: %v", err) + } + + tests := []struct { + name string + config config.Config + value int + }{ + {name: "zero max size", config: config.LogMaxSize, value: 0}, + {name: "negative max age", config: config.LogMaxAge, value: -1}, + {name: "negative max backups", config: config.LogMaxBackups, value: -1}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + original := test.config.Value() + test.config.Set(test.value) + defer test.config.Set(original) + if err := config.ValidateLoggingConfig(); err == nil { + t.Fatal("expected validation error") + } + }) + } + + config.LogMaxAge.Set(0) + config.LogMaxBackups.Set(0) + if err := config.ValidateLoggingConfig(); err != nil { + t.Fatalf("disabled pruning returned an error: %v", err) + } +} + +func TestValidateLoggingConfigIgnoredWithoutLogFile(t *testing.T) { + oldLogFile := config.LogFile.Value() + oldMaxSize := config.LogMaxSize.Value() + defer func() { + config.LogFile.Set(oldLogFile) + config.LogMaxSize.Set(oldMaxSize) + }() + + config.LogFile.Set("") + config.LogMaxSize.Set(0) + if err := config.ValidateLoggingConfig(); err != nil { + t.Fatalf("file logging limits should be ignored without a log file: %v", err) + } +} + +func TestValidateLoggingConfigRejectsDirectoryLogPath(t *testing.T) { + oldLogFile := config.LogFile.Value() + oldMaxSize := config.LogMaxSize.Value() + oldMaxAge := config.LogMaxAge.Value() + oldMaxBackups := config.LogMaxBackups.Value() + defer func() { + config.LogFile.Set(oldLogFile) + config.LogMaxSize.Set(oldMaxSize) + config.LogMaxAge.Set(oldMaxAge) + config.LogMaxBackups.Set(oldMaxBackups) + }() + + logPath := t.TempDir() + config.LogFile.Set(logPath) + config.LogMaxSize.Set(config.DefaultLogMaxSize) + config.LogMaxAge.Set(config.DefaultLogMaxAge) + config.LogMaxBackups.Set(config.DefaultLogMaxBackups) + + err := config.ValidateLoggingConfig() + if err == nil { + t.Fatal("expected a directory-valued log path to be rejected") + } + if !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("unexpected validation error: %v", err) + } +} diff --git a/go.mod b/go.mod index c7b33878..47d36065 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( go.uber.org/mock v0.5.2 golang.org/x/net v0.49.0 golang.org/x/sys v0.41.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) require ( diff --git a/go.sum b/go.sum index 3471745a..aa84e5d8 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logger/log.go b/logger/log.go index 546937f8..e7a8a666 100644 --- a/logger/log.go +++ b/logger/log.go @@ -38,7 +38,9 @@ func setupLogger() (*logr.Logger, error) { } // emit logs to file if configured - if fileLogWriter := getFileLogLevelWriter(); fileLogWriter != nil { + if fileLogWriter, err := getFileLogLevelWriter(); err != nil { + return nil, err + } else if fileLogWriter != nil { options.Writers = append(options.Writers, fileLogWriter) } diff --git a/logger/log_windows.go b/logger/log_windows.go index 78986780..a0fe2193 100644 --- a/logger/log_windows.go +++ b/logger/log_windows.go @@ -48,7 +48,9 @@ func setupLogger() (*logr.Logger, error) { } // emit logs to file if configured - if fileLogWriter := getFileLogLevelWriter(); fileLogWriter != nil { + if fileLogWriter, err := getFileLogLevelWriter(); err != nil { + return nil, err + } else if fileLogWriter != nil { options.Writers = append(options.Writers, fileLogWriter) } diff --git a/logger/utils.go b/logger/utils.go index d433aa77..8526a8ee 100644 --- a/logger/utils.go +++ b/logger/utils.go @@ -19,10 +19,10 @@ package logger import ( "io" - "os" "github.com/bloodhoundad/azurehound/v2/config" "github.com/go-logr/logr" + "gopkg.in/natefinch/lumberjack.v2" ) var ( @@ -30,15 +30,22 @@ var ( fileLogWriter io.Writer ) -func getFileLogLevelWriter() io.Writer { +func getFileLogLevelWriter() (io.Writer, error) { if fileLogWriter != nil { - return fileLogWriter + return fileLogWriter, nil + } else if err := config.ValidateLoggingConfig(); err != nil { + return nil, err } else if logfile, ok := config.LogFile.Value().(string); !ok || logfile == "" { - return nil - } else if file, err := os.OpenFile(logfile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err != nil { - return nil + return nil, nil } else { - return file + fileLogWriter = &lumberjack.Logger{ + Filename: logfile, + MaxSize: config.LogMaxSize.Value().(int), + MaxAge: config.LogMaxAge.Value().(int), + MaxBackups: config.LogMaxBackups.Value().(int), + Compress: config.LogCompress.Value().(bool), + } + return fileLogWriter, nil } } diff --git a/logger/utils_test.go b/logger/utils_test.go new file mode 100644 index 00000000..7b76fd40 --- /dev/null +++ b/logger/utils_test.go @@ -0,0 +1,187 @@ +// Copyright (C) 2022 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 . + +package logger + +import ( + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/bloodhoundad/azurehound/v2/config" + "gopkg.in/natefinch/lumberjack.v2" +) + +func TestGetFileLogLevelWriterUsesLumberjack(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "azurehound.log") + restore := setFileLoggingConfig(logPath) + defer restore() + + config.LogMaxSize.Set(25) + config.LogMaxAge.Set(7) + config.LogMaxBackups.Set(5) + config.LogCompress.Set(false) + + fileWriter, err := getFileLogLevelWriter() + if err != nil { + t.Fatal(err) + } + writer, ok := fileWriter.(*lumberjack.Logger) + if !ok { + t.Fatalf("expected *lumberjack.Logger, got %T", fileLogWriter) + } + if writer.Filename != logPath { + t.Errorf("Filename = %q, want %q", writer.Filename, logPath) + } + if writer.MaxSize != 25 { + t.Errorf("MaxSize = %d, want 25", writer.MaxSize) + } + if writer.MaxAge != 7 { + t.Errorf("MaxAge = %d, want 7", writer.MaxAge) + } + if writer.MaxBackups != 5 { + t.Errorf("MaxBackups = %d, want 5", writer.MaxBackups) + } + if writer.Compress { + t.Error("Compress = true, want false") + } +} + +func TestGetLoggerRejectsDirectoryLogPath(t *testing.T) { + logPath := t.TempDir() + restore := setFileLoggingConfig(logPath) + defer restore() + + if _, err := GetLogger(); err == nil { + t.Fatal("expected logger setup to reject a directory-valued log path") + } + + fileInfo, err := os.Stat(logPath) + if err != nil { + t.Fatalf("stat log directory: %v", err) + } + if !fileInfo.IsDir() { + t.Fatalf("log path was changed from a directory: mode = %v", fileInfo.Mode()) + } + if fileLogWriter != nil { + t.Fatalf("file log writer was created for a directory: %T", fileLogWriter) + } +} + +func TestGetLoggerRejectsInaccessibleLogFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file modes do not reliably prevent writes on Windows") + } + + logPath := filepath.Join(t.TempDir(), "azurehound.log") + if err := os.WriteFile(logPath, nil, 0400); err != nil { + t.Fatal(err) + } + defer func() { + if err := os.Chmod(logPath, 0600); err != nil { + t.Errorf("restore log file permissions: %v", err) + } + }() + + if file, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0); err == nil { + _ = file.Close() + t.Skip("test requires write permissions to be enforced") + } + + restore := setFileLoggingConfig(logPath) + defer restore() + + if _, err := GetLogger(); err == nil { + t.Fatal("expected logger setup to reject an inaccessible log file") + } + + fileInfo, err := os.Stat(logPath) + if err != nil { + t.Fatalf("stat log file: %v", err) + } + if !fileInfo.Mode().IsRegular() { + t.Fatalf("log path was changed from a regular file: mode = %v", fileInfo.Mode()) + } + if fileLogWriter != nil { + t.Fatalf("file log writer was created for an inaccessible file: %T", fileLogWriter) + } + if log != nil { + t.Fatal("logger was cached for an inaccessible log file") + } +} + +func TestGetLoggerWritesToRotatingFile(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "logs", "azurehound.log") + restore := setFileLoggingConfig(logPath) + defer restore() + + log, err := GetLogger() + if err != nil { + t.Fatal(err) + } + log.Info("rotating file log", "answer", 42) + resetLoggerForTest() + + contents, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(contents), "rotating file log") { + t.Fatalf("log file does not contain emitted message: %s", contents) + } +} + +func setFileLoggingConfig(logPath string) func() { + oldLogFile := config.LogFile.Value() + oldMaxSize := config.LogMaxSize.Value() + oldMaxAge := config.LogMaxAge.Value() + oldMaxBackups := config.LogMaxBackups.Value() + oldCompress := config.LogCompress.Value() + oldJSON := config.JsonLogs.Value() + oldVerbosity := config.VerbosityLevel.Value() + + resetLoggerForTest() + config.LogFile.Set(logPath) + config.LogMaxSize.Set(config.DefaultLogMaxSize) + config.LogMaxAge.Set(config.DefaultLogMaxAge) + config.LogMaxBackups.Set(config.DefaultLogMaxBackups) + config.LogCompress.Set(true) + config.JsonLogs.Set(true) + config.VerbosityLevel.Set(0) + + return func() { + resetLoggerForTest() + config.LogFile.Set(oldLogFile) + config.LogMaxSize.Set(oldMaxSize) + config.LogMaxAge.Set(oldMaxAge) + config.LogMaxBackups.Set(oldMaxBackups) + config.LogCompress.Set(oldCompress) + config.JsonLogs.Set(oldJSON) + config.VerbosityLevel.Set(oldVerbosity) + } +} + +func resetLoggerForTest() { + if closer, ok := fileLogWriter.(io.Closer); ok { + _ = closer.Close() + } + fileLogWriter = nil + log = nil +}