leakhound ๐Ÿ•

May 30, 2026 ยท View on GitHub

A Go static analysis tool that detects accidental logging of sensitive struct fields tagged with sensitive:"true", preventing data leaks in logs.

Badges

GoTestAndBuild License Release Go Report Card

Features

  • Data Flow Analysis: Tracks sensitive data through variables, function parameters, and return values
  • Cross-Package Tracking: Follows sensitive values across import boundaries โ€” flags both sensitive return values (LH0005) and sink parameters (LH0006) in other packages
  • Detects if struct fields tagged with sensitive:"true" are being output by logging functions
  • Supports multiple logging packages: log/slog, log, and fmt
  • Suppression: Suppress specific findings with //noleak:LH0003 inline comments or globally via config
  • Configurable: Add support for third-party logging libraries (zap, zerolog, logrus, etc.) via YAML configuration
  • Zero runtime overhead (static analysis only)

Installation

As a CLI tool

go install github.com/nilpoona/leakhound@latest

Usage

1. Tag sensitive fields

package main

import (
    "fmt"
    "log/slog"
)

type User struct {
    ID       int
    Name     string
    Password string `sensitive:"true" json:"-"`
    APIKey   string `sensitive:"true" json:"-"`
    Email    string `sensitive:"true" json:"email"`
}

type Config struct {
    Host     string
    Port     int
    Token    string `sensitive:"true"`
    Database string
}

2. Run static analysis

Run as a CLI tool

# Inspect the current directory (whole-program: cross-package tracking enabled)
leakhound ./...

# Inspect a specific package
leakhound ./internal/...

# Per-package mode (legacy, no cross-package tracking โ€” useful for go vet style integrations)
leakhound --single-package ./...

By default leakhound runs in whole-program mode, loading the target packages plus their transitive dependencies (packages.Load with NeedDeps) so it can follow sensitive values across import boundaries. Use --single-package to fall back to the per-package driver if you need go vet-compatible output.

Output Formats

leakhound supports multiple output formats for different use cases:

Text format (default)

# Human-readable output to stderr
leakhound ./...

This format is compatible with existing tooling and outputs findings in the standard format: /path/to/file.go:line:col: message

SARIF format (v2.1.0)

# Machine-readable JSON output to stdout
leakhound --format=sarif ./...

# Save SARIF output to file
leakhound --format=sarif ./... > results.sarif

SARIF (Static Analysis Results Interchange Format) is an industry-standard format for static analysis results. It integrates with:

  • GitHub Advanced Security (Code Scanning)
  • Visual Studio Code
  • Azure DevOps
  • GitLab
  • Other CI/CD platforms

The SARIF output includes:

  • Rule metadata with severity levels
  • Precise source locations (file path, line, column)
  • Detailed descriptions for each finding
  • Tool version information

3. Nested struct support

leakhound can also detect sensitive fields in nested/embedded structs:

type Config struct {
    Secret string `sensitive:"true"`
}

type WrapConfig struct {
    Config  // Embedded struct with sensitive field
    Description string
}

wrapConfig := WrapConfig{...}

// โœ… Both cases will be detected
slog.Info("wrapConfig", wrapConfig)              // Detects embedded sensitive fields
slog.Info("secret", wrapConfig.Config.Secret)    // Detects nested field access

Design Philosophy

Why static analysis?

leakhound uses static analysis rather than runtime masking.

Advantages of Static Analysis

  • โœ… Preventative: Find issues at the code review stage.
  • โœ… Zero runtime cost: No performance impact during execution.
  • โœ… Reliable prevention: Blocks sensitive data before it can be logged.

Supported Logging Libraries

Built-in Support (No Configuration Required)

  • โœ… log/slog (Go 1.21+)
  • โœ… *slog.Logger type custom loggers
  • โœ… log (standard log package)
  • โœ… *log.Logger type custom loggers
  • โœ… fmt (Printf, Println, Print, etc.)

Third-party Libraries (via Configuration)

Configuration

Quick Start

For standard libraries (log, log/slog, fmt), no configuration is needed. Just run:

leakhound ./...

Adding Third-party Logger Support

To detect sensitive data in third-party logging libraries like zap, zerolog, or logrus: Note: The provided configuration files only cover commonly used methods for each library. They do not cover all methods, so please customize them as needed.

  1. Download a pre-made configuration:
# For zap
curl -o .leakhound.yaml https://raw.githubusercontent.com/nilpoona/leakhound/main/examples/zap.yaml

# For zerolog
curl -o .leakhound.yaml https://raw.githubusercontent.com/nilpoona/leakhound/main/examples/zerolog.yaml

# For logrus
curl -o .leakhound.yaml https://raw.githubusercontent.com/nilpoona/leakhound/main/examples/logrus.yaml
  1. Run leakhound:
leakhound ./...

The tool will automatically find .leakhound.yaml in the current directory.

Custom Configuration

Create a .leakhound.yaml file in your project root:

targets:
  - package: "go.uber.org/zap"
    methods:
      - receiver: "*Logger"
        names:
          - "Info"
          - "Debug"
          - "Error"
      - receiver: "*SugaredLogger"
        names:
          - "Infow"
          - "Debugw"

Or specify a custom path:

leakhound --config path/to/config.yaml ./...

Configuration Format

targets:
  - package: "go.uber.org/zap"           # Package import path
    functions:                            # Package-level functions (optional)
      - "Info"
      - "Debug"
    methods:                              # Methods on specific types (optional)
      - receiver: "*Logger"               # Receiver type (* for pointer)
        names:                            # Method names
          - "Info"
          - "Debug"

suppress:
  rules:                                  # Rule IDs to suppress globally (optional)
    - "LH0003"

Requirements:

  • At least one of functions or methods must be specified per target
  • Package paths must be lowercase: a-z, 0-9, ., -, /
  • Function and method names must be valid Go identifiers
  • Receiver types can be pointer (*Logger) or value (Logger)
  • suppress.rules values must be one of: LH0001, LH0002, LH0003, LH0004, LH0005, LH0006

Limits (to prevent abuse):

  • Maximum 20 targets
  • Maximum 50 functions per target
  • Maximum 10 method configs per target
  • Maximum 50 method names per method config

See examples/ for more configuration examples.

Suppression

Sometimes a specific finding is intentional or already handled upstream. leakhound provides two ways to suppress findings.

Inline comment suppression

Place a //noleak:RULE_ID directive on the same line as the flagged statement, or on the immediately preceding line:

// Same-line suppression
slog.Info("user", u) //noleak:LH0003

// Preceding-line suppression (useful for multi-line calls)
//noleak:LH0003
slog.Info(
    "user",
    u,
)

// Suppress all rules on this line
slog.Info("user", u) //noleak:all

// Trailing explanation is allowed
slog.Info("user", u) //noleak:LH0003 MaskedUser applied upstream

Note: // noleak: (space after //) is not recognised. The directive must start with //noleak:.

Config-level suppression

To suppress a rule globally across the entire project, add it to .leakhound.yaml:

suppress:
  rules:
    - "LH0003"   # never report struct-level findings

Valid values: LH0001, LH0002, LH0003, LH0004, LH0005, LH0006.

Config-level suppressions appear in SARIF output with kind: "external"; inline comment suppressions appear with kind: "inSource".

Advanced Detection: Data Flow Tracking

Variable Assignments

// โœ… Variable assignment tracking
password := user.Password
slog.Info("msg", "pass", password)  // Detected!
log.Println("password:", password)  // Detected!
fmt.Printf("secret: %s", password)  // Detected!

Function Parameters (same package)

// โœ… Function parameter tracking
func logValue(val string) {
    slog.Info("msg", val)  // Detected!
}

password := user.Password
logValue(password)  // Tracks sensitive data through function call

Nested Function Calls

// โœ… Nested function call tracking 
func inner(data string) {
    log.Println(data)  // Detected!
}

func outer(val string) {
    inner(val)  // Tracks through multiple levels
}

password := user.Password
outer(password)  // Tracks up to 5 levels deep

Cross-Package Tracking (whole-program mode)

// pkg secret
func GetPassword(u User) string { return u.Password }
func LogIt(p string) { slog.Info("p", "v", p) }

// pkg app
import "example.com/myapp/secret"

slog.Info("pw", "v", secret.GetPassword(u))  // โš ๏ธ LH0005 (sensitive return)
secret.LogIt(u.Password)                      // โš ๏ธ LH0006 (sensitive sink)

Cross-package tracking is enabled by default; use --single-package to disable.

Return Values

// โœ… Single return value tracking
func getPassword(user User) string {
    return user.Password
}

// Direct use
slog.Info("msg", getPassword(user))  // Detected!

// Via variable
password := getPassword(user)
log.Println(password)  // Detected!

// โœ… Multi-value return tracking 
func getPasswordAndErr(user User) (string, error) {
    return user.Password, nil
}

password, err := getPasswordAndErr(user)
slog.Info("msg", password)  // Detected! (position 0 is sensitive)
slog.Info("msg", err)       // Not detected (position 1 is not sensitive)

Limitations

Due to the nature of static analysis, there are the following limitations:

Cases that cannot be detected

// โŒ Variadic arguments (out of scope)
func logMultiple(vals ...string) {
    for _, v := range vals {
        slog.Info("msg", v)
    }
}
password := user.Password
logMultiple("safe", password)  // Not tracked

// โŒ Reassignments (flow-sensitive analysis not implemented)
password := user.Password  // Sensitive
password = "safe-value"    // Overwritten
slog.Info("msg", password) // May still be reported

// โŒ Via reflection
val := reflect.ValueOf(user).FieldByName("Password")
slog.Info("msg", "pass", val.Interface())

// โŒ Via an interface
var data interface{} = user.Password
slog.Info("msg", "pass", data)

Cases that can be detected

slog package (including *slog.Logger type)

// โœ… Direct field access
slog.Info("msg", "pass", user.Password)
logger.Info("msg", "pass", user.Password)  // logger is *slog.Logger

// โœ… Variable assignments
password := user.Password
slog.Info("msg", "pass", password)  // Tracked!
logger.Error("msg", "pass", password)  // Tracked!

// โœ… When wrapped by slog.String, etc.
slog.Info("msg", slog.String("pass", user.Password))

// โœ… Via a pointer
userPtr := &user
slog.Info("msg", "pass", userPtr.Password)

// โœ… Entire struct containing sensitive fields
slog.Info("user data", user)                    // Detects if user has sensitive fields
slog.Info("user data", slog.Any("data", user))  // Also detects in nested function calls
logger.Error("config", config)                  // *slog.Logger detects struct with sensitive fields

// โœ… All *slog.Logger methods
logger.Debug("msg", "secret", user.Password)
logger.Error("msg", "secret", user.Password)
logger.Warn("msg", "secret", user.Password)
logger.InfoContext(ctx, "msg", "secret", user.Password)
logger.ErrorContext(ctx, "msg", "secret", user.Password)
logger.WarnContext(ctx, "msg", "secret", user.Password)
logger.DebugContext(ctx, "msg", "secret", user.Password)
logger.Log(ctx, slog.LevelInfo, "msg", "secret", user.Password)
logger.LogAttrs(ctx, slog.LevelInfo, "msg", slog.String("pass", user.Password))

// โœ… With method chaining (edge case)
logger.With("key", "val").Info("config", config)  // Detects even after With()

// โœ… Nested/embedded structs with sensitive fields
type WrapConfig struct {
    Config  // Embedded struct with sensitive field
}
wrapConfig := WrapConfig{...}
slog.Info("wrapConfig", wrapConfig)              // Detects embedded sensitive fields
slog.Info("secret", wrapConfig.Config.Secret)    // Detects nested field access

log package (including *log.Logger type)

// โœ… Direct field access
log.Print("secret:", user.Password)
log.Printf("secret: %s", user.Password)
log.Println("secret:", user.Password)
customLogger.Print("token:", config.Token)  // customLogger is *log.Logger

// โœ… Variable assignments
p := user.Password
log.Println("password:", p)  // Tracked!
customLogger.Print("token:", p)  // Tracked!

// โœ… All log package functions
log.Fatal("secret:", user.Password)
log.Fatalf("secret: %s", user.Password)
log.Fatalln("secret:", user.Password)
log.Panic("secret:", user.Password)
log.Panicf("secret: %s", user.Password)
log.Panicln("secret:", user.Password)

// โœ… Entire struct containing sensitive fields
log.Print("config:", config)              // Detects if config has sensitive fields
log.Printf("config: %+v", config)         // Detects with format verbs
customLogger.Println("user:", user)       // *log.Logger detects struct with sensitive fields

// โœ… All *log.Logger methods
customLogger.Fatal("secret:", user.Password)
customLogger.Fatalf("secret: %s", user.Password)
customLogger.Fatalln("secret:", user.Password)
customLogger.Panic("secret:", user.Password)
customLogger.Panicf("secret: %s", user.Password)
customLogger.Panicln("secret:", user.Password)
customLogger.Output(2, user.Password)

// โœ… Nested/embedded structs with sensitive fields
type WrapConfig struct {
    Config  // Embedded struct with sensitive field
}
wrapConfig := WrapConfig{...}
log.Print("wrapConfig:", wrapConfig)             // Detects embedded sensitive fields
log.Println("secret:", wrapConfig.Config.Secret) // Detects nested field access

fmt package

// โœ… Direct field access
fmt.Println(user.Password)
fmt.Printf("password: %s", user.Password)
fmt.Print("token:", config.Token)

// โœ… Variable assignments
secret := config.APIKey
fmt.Printf("key: %s", secret)  // Tracked!

// โœ… Via a pointer
userPtr := &user
fmt.Println(userPtr.Password)

// โœ… Entire struct containing sensitive fields
fmt.Println(user)           // Detects if user has sensitive fields
fmt.Printf("%+v", user)     // Detects with format verbs
fmt.Printf("%#v", config)   // Detects with any format

// โœ… Multiple arguments
fmt.Println("User:", user.Name, "Pass:", user.Password)  // Detects Password

// โœ… Nested/embedded structs with sensitive fields
type WrapConfig struct {
    Config  // Embedded struct with sensitive field
}
wrapConfig := WrapConfig{...}
fmt.Println("wrapConfig:", wrapConfig)             // Detects embedded sensitive fields
fmt.Printf("secret: %s", wrapConfig.Config.Secret) // Detects nested field access

Example Detection Output

Each finding includes a rule ID suffix ([LH0001]โ€“[LH0006]) so you know which ID to use in a suppression directive:

$ leakhound ./...
./main.go:15:2: sensitive field 'User.Password' should not be logged (tagged with sensitive:"true") [LH0004]
./main.go:18:27: variable "password" contains sensitive field "User.Password" (tagged with sensitive:"true") [LH0001]
./main.go:23:19: variable "val" contains sensitive field "User.Password" (tagged with sensitive:"true") [LH0001]
./config.go:34:19: function call returns sensitive field "Config.APIKey" (tagged with sensitive:"true") [LH0002]
./user.go:10:14: struct 'User' contains sensitive fields and should not be logged entirely [LH0003]
./app.go:13:25: cross-package function call returns sensitive field "User.Password" (callee in "example.com/secret") [LH0005]
./app.go:20:15: sensitive field "User.Password" is passed to cross-package function "LogIt" whose parameter "payload" is logged downstream [LH0006]
Rule IDMeaning
LH0001Variable contains sensitive data
LH0002Function call returns sensitive data
LH0003Struct with sensitive fields logged entirely
LH0004Sensitive struct field directly accessed
LH0005Cross-package function returns sensitive data (logged in caller)
LH0006Sensitive value passed to cross-package function that logs the parameter