Files
trufflehog/pkg/detectors/humioapitoken/humioapitoken_test.go
Dave HubbardandCursor 080c22fb8e [INT-942] Add HumioAPIToken detector (#5258)
* [INT-942] Add HumioAPIToken detector

Add a TruffleHog detector for Humio/CrowdStrike Falcon LogScale API
tokens. These are personal and repository-scoped tokens in a
tilde-delimited base62 format ({24 or 32}~{44}) that grant read/write
access to LogScale repositories via the REST and GraphQL APIs.

Verification uses the read-only GET /api/v1/health-json endpoint.
Known SaaS regions (US and EU) are tried automatically; self-hosted or
CrowdStrike regional instances are supported via explicit endpoint
configuration. ExtraData surfaces token type (Personal API Token vs
Repository API Token) based on prefix length.

Feature-flag gated behind HumioAPITokenDetectorEnabled.

Co-authored-by: Cursor <[email protected]>

* Use realistic test inputs for pattern matching

Replace synthetic one-liner inputs with multi-line snippets that
mirror real-world contexts: Python client config, .env file, Docker
Compose environment block. Adopt require.NoError from testify to
match the convention in solarwindsobservability_test.go.

Co-authored-by: Cursor <[email protected]>

* Use url.JoinPath for verification endpoint URLs

Replace string concatenation with url.JoinPath to safely construct
verification URLs from base endpoints.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Cursor <[email protected]>
2026-09-08 09:22:21 -04:00

293 lines
8.8 KiB
Go

package humioapitoken
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/engine/ahocorasick"
)
var (
// 32-char prefix + tilde + 44-char suffix (repository API token)
validPattern32 = "pHUw1oLASALFmt2ppNvwCR0Meo2nHQ15~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR"
// 24-char prefix + tilde + 44-char suffix (personal API token)
validPattern24 = "abcDefGhiJklMnoPqrStUvWx~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR"
)
func TestHumioAPIToken_Pattern(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
tests := []struct {
name string
input string
want []string
}{
{
name: "valid pattern - python client config",
input: `
from humiolib.HumioClient import HumioClient
client = HumioClient(
base_url="https://cloud.us.humio.com",
api_token="pHUw1oLASALFmt2ppNvwCR0Meo2nHQ15~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR",
)
results = client.search_repo("sandbox", "loglevel=ERROR")`,
want: []string{validPattern32},
},
{
name: "valid pattern - env file with PAT",
input: `
# LogScale personal access token for CI/CD pipeline
LOGSCALE_API_TOKEN=abcDefGhiJklMnoPqrStUvWx~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR
LOGSCALE_BASE_URL=https://cloud.humio.com
LOG_LEVEL=info`,
want: []string{validPattern24},
},
{
name: "valid pattern - logscale keyword in docker compose",
input: `
services:
log-shipper:
image: fluent/fluent-bit:latest
environment:
- LOGSCALE_TOKEN=pHUw1oLASALFmt2ppNvwCR0Meo2nHQ15~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR
- LOGSCALE_HOST=https://cloud.us.humio.com
restart: always`,
want: []string{validPattern32},
},
{
name: "valid pattern - ignore duplicate",
input: fmt.Sprintf("humio token = '%s' | '%s'", validPattern32, validPattern32),
want: []string{validPattern32},
},
{
name: "valid pattern - token far from keyword",
input: `
# Humio repository configuration
# Generated by setup wizard on 2025-01-15
api_token = "pHUw1oLASALFmt2ppNvwCR0Meo2nHQ15~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR"`,
want: []string{validPattern32},
},
{
name: "invalid pattern - tilde present but suffix too short",
input: `
from humiolib.HumioClient import HumioClient
client = HumioClient(api_token="pHUw1oLASALFmt2ppNvwCR0Meo2nHQ15~ECViF0Ce95uIqFGSS")`,
want: []string{},
},
{
name: "invalid pattern - tilde present but segments too short",
input: `
# humio config
token = "abc~def"`,
want: []string{},
},
{
name: "invalid pattern - wrong prefix length (28 chars)",
input: `
# humio config
token = "abcDefGhiJklMnoPqrStUvWxYzAb~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR"`,
want: []string{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(test.input))
if len(matchedDetectors) == 0 {
t.Errorf("keywords '%v' not matched by: %s", d.Keywords(), test.input)
return
}
results, err := d.FromData(context.Background(), false, []byte(test.input))
require.NoError(t, err)
if len(results) != len(test.want) {
t.Errorf("expected %d results, got %d", len(test.want), len(results))
return
}
actual := make(map[string]struct{}, len(results))
for _, r := range results {
if len(r.RawV2) > 0 {
actual[string(r.RawV2)] = struct{}{}
} else {
actual[string(r.Raw)] = struct{}{}
}
}
expected := make(map[string]struct{}, len(test.want))
for _, v := range test.want {
expected[v] = struct{}{}
}
if diff := cmp.Diff(expected, actual); diff != "" {
t.Errorf("%s diff: (-want +got)\n%s", test.name, diff)
}
})
}
}
// Exercise the verification logic against a mock health-json server. Each
// HTTP status code maps to a specific verified/error outcome — 403 counts as
// verified because the server recognized the token (just blocked by IP filter).
func TestHumioAPIToken_Verification(t *testing.T) {
tests := []struct {
name string
statusCode int
wantVerified bool
wantErr bool
}{
{"200 - valid token", http.StatusOK, true, false},
{"401 - unknown token", http.StatusUnauthorized, false, false},
{"403 - recognized but blocked", http.StatusForbidden, true, false},
{"500 - server error", http.StatusInternalServerError, false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
}))
defer ts.Close()
s := Scanner{}
s.SetCloudEndpoint(ts.URL)
s.UseCloudEndpoint(true)
input := fmt.Sprintf("humio_token = '%s'", validPattern32)
results, err := s.FromData(context.Background(), true, []byte(input))
if err != nil {
t.Fatalf("FromData error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.Verified != tt.wantVerified {
t.Errorf("Verified = %v, want %v", r.Verified, tt.wantVerified)
}
if tt.wantErr && r.VerificationError() == nil {
t.Error("expected verification error, got nil")
}
if !tt.wantErr && r.VerificationError() != nil {
t.Errorf("unexpected verification error: %v", r.VerificationError())
}
})
}
}
// Verify that a successful verification on a later endpoint clears any
// error from an earlier failed attempt (e.g. first endpoint returns 500,
// second returns 200).
func TestHumioAPIToken_Verification_StaleErrorCleared(t *testing.T) {
callCount := 0
ts500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusInternalServerError)
}))
defer ts500.Close()
ts200 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
defer ts200.Close()
s := Scanner{}
_ = s.SetConfiguredEndpoints(ts500.URL, ts200.URL)
input := fmt.Sprintf("humio_token = '%s'", validPattern32)
results, err := s.FromData(context.Background(), true, []byte(input))
if err != nil {
t.Fatalf("FromData error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if !r.Verified {
t.Error("expected Verified = true after second endpoint succeeded")
}
if r.VerificationError() != nil {
t.Errorf("stale verification error not cleared: %v", r.VerificationError())
}
}
// Verify that a clean 401 from the wrong region does not erase a transient
// error from an earlier endpoint. The verification error should survive so
// consumers know the result is uncertain, not definitively "not valid."
func TestHumioAPIToken_Verification_ErrorPreservedAcross401(t *testing.T) {
ts500 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer ts500.Close()
ts401 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer ts401.Close()
s := Scanner{}
_ = s.SetConfiguredEndpoints(ts500.URL, ts401.URL)
input := fmt.Sprintf("humio_token = '%s'", validPattern32)
results, err := s.FromData(context.Background(), true, []byte(input))
if err != nil {
t.Fatalf("FromData error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
r := results[0]
if r.Verified {
t.Error("expected Verified = false")
}
if r.VerificationError() == nil {
t.Error("expected verification error to be preserved after 401 from another endpoint, got nil")
}
}
func TestHumioAPIToken_TokenType(t *testing.T) {
tests := []struct {
name string
token string
want string
}{
{"24-char prefix is Personal API Token", validPattern24, "Personal API Token"},
{"32-char prefix is Repository API Token", validPattern32, "Repository API Token"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := humioTokenType(tt.token)
if got != tt.want {
t.Errorf("humioTokenType() = %q, want %q", got, tt.want)
}
})
}
}
// Data without humio/logscale context must not reach this detector — the
// Aho-Corasick pre-filter should reject the chunk.
func TestHumioAPIToken_NoKeywordMatch(t *testing.T) {
d := Scanner{}
ahoCorasickCore := ahocorasick.NewAhoCorasickCore([]detectors.Detector{d})
input := fmt.Sprintf("some_generic_token = '%s'", validPattern32)
matchedDetectors := ahoCorasickCore.FindDetectorMatches([]byte(input))
if len(matchedDetectors) != 0 {
t.Errorf("expected no keyword match for input without humio/logscale context, got %d", len(matchedDetectors))
}
}