[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]>
This commit is contained in:
@@ -576,6 +576,7 @@ func run(state overseer.State, logSync func() error) {
|
||||
feature.MSTeamsWebhookV2DetectorEnabled.Store(true)
|
||||
feature.SolarwindsDetectorEnabled.Store(true)
|
||||
feature.WeightsAndBiasesV2DetectorEnabled.Store(true)
|
||||
feature.HumioAPITokenDetectorEnabled.Store(true)
|
||||
|
||||
conf := &config.Config{}
|
||||
if *configFilename != "" {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package humioapitoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
regexp "github.com/wasilibs/go-re2"
|
||||
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
|
||||
)
|
||||
|
||||
type Scanner struct {
|
||||
client *http.Client
|
||||
detectors.EndpointSetter
|
||||
}
|
||||
|
||||
var _ detectors.Detector = (*Scanner)(nil)
|
||||
var _ detectors.EndpointCustomizer = (*Scanner)(nil)
|
||||
var _ detectors.CloudProvider = (*Scanner)(nil)
|
||||
|
||||
func (Scanner) CloudEndpoint() string { return "https://cloud.us.humio.com" }
|
||||
|
||||
// additionalCloudEndpoints lists Humio/LogScale SaaS regions beyond the
|
||||
// primary one returned by CloudEndpoint(). The CloudProvider interface
|
||||
// only supports a single endpoint, so these are passed as found
|
||||
// endpoints during verification to ensure tokens from any region are
|
||||
// verified even when the scanned data contains no URL.
|
||||
var additionalCloudEndpoints = []string{
|
||||
"https://cloud.humio.com",
|
||||
}
|
||||
|
||||
var (
|
||||
defaultClient = common.SaneHttpClient()
|
||||
|
||||
// API tokens are base62 strings with a tilde (~) separator: a 24- or
|
||||
// 32-character prefix and a fixed 44-character suffix. The tilde +
|
||||
// exact segment lengths make this format distinctive enough that no
|
||||
// PrefixRegex or surrounding-context anchor is needed.
|
||||
// Example: pHUw1oLASALFmt2ppNvwCR0Meo2nHQ15~ECViF0Ce95uIqFGSSatjKWX71EOzvkpVGSuc3zGYqJgR
|
||||
keyPat = regexp.MustCompile(`\b([A-Za-z0-9]{24}(?:[A-Za-z0-9]{8})?~[A-Za-z0-9]{44})\b`)
|
||||
)
|
||||
|
||||
func (s Scanner) Type() detector_typepb.DetectorType {
|
||||
return detector_typepb.DetectorType_HumioAPIToken
|
||||
}
|
||||
|
||||
func (s Scanner) Keywords() []string {
|
||||
// The tilde separator in the token format is distinctive but too common
|
||||
// in source code (URLs, paths, shell, bitwise ops) to use as a keyword.
|
||||
// Te regex handles precision within matched chunks.
|
||||
return []string{"humio", "logscale"}
|
||||
}
|
||||
|
||||
func (s Scanner) Description() string {
|
||||
return "Humio/CrowdStrike Falcon LogScale is a log management platform. API tokens grant read/write access to a specific LogScale repository via the REST and GraphQL APIs."
|
||||
}
|
||||
|
||||
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
|
||||
dataStr := string(data)
|
||||
|
||||
tokenMatches := make(map[string]struct{})
|
||||
for _, match := range keyPat.FindAllStringSubmatch(dataStr, -1) {
|
||||
t := strings.TrimSpace(match[1])
|
||||
tokenMatches[t] = struct{}{}
|
||||
}
|
||||
|
||||
for token := range tokenMatches {
|
||||
r := detectors.Result{
|
||||
DetectorType: detector_typepb.DetectorType_HumioAPIToken,
|
||||
Raw: []byte(token),
|
||||
SecretParts: map[string]string{"key": token},
|
||||
ExtraData: map[string]string{"token_type": humioTokenType(token)},
|
||||
}
|
||||
|
||||
if verify {
|
||||
client := s.client
|
||||
if client == nil {
|
||||
client = defaultClient
|
||||
}
|
||||
|
||||
// Verification only targets known SaaS regions (US via
|
||||
// CloudEndpoint, EU via additionalCloudEndpoints) plus any
|
||||
// endpoints explicitly configured by the operator (e.g.
|
||||
// self-hosted instances). URLs discovered in scanned data are
|
||||
// not used — arbitrary hosts can return 403 for non-auth
|
||||
// reasons (WAF, CDN), causing false verification.
|
||||
//
|
||||
// Only non-nil errors overwrite lastErr so that a clean 401
|
||||
// from the wrong region doesn't erase a transient failure
|
||||
// (500, timeout) from a region that might be authoritative.
|
||||
var lastErr error
|
||||
for _, baseURL := range s.Endpoints(additionalCloudEndpoints...) {
|
||||
isVerified, vErr := verifyAPIToken(ctx, client, baseURL, token)
|
||||
r.Verified = isVerified
|
||||
if vErr != nil {
|
||||
lastErr = vErr
|
||||
}
|
||||
if isVerified {
|
||||
lastErr = nil
|
||||
r.ExtraData["endpoint"] = baseURL
|
||||
break
|
||||
}
|
||||
}
|
||||
r.SetVerificationError(lastErr, token)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// humioTokenType classifies the token based on prefix length: 24-char
|
||||
// prefixes are personal API tokens (PATs), 32-char prefixes are
|
||||
// repository/view API tokens.
|
||||
func humioTokenType(token string) string {
|
||||
idx := strings.Index(token, "~")
|
||||
if idx == 24 {
|
||||
return "Personal API Token"
|
||||
}
|
||||
return "Repository API Token"
|
||||
}
|
||||
|
||||
// verifyAPIToken hits the health-json endpoint, which is read-only and
|
||||
// produces no side effects. The endpoint requires authentication and
|
||||
// returns cluster health status on success.
|
||||
//
|
||||
// Response semantics: 200 means fully valid. 403 signals a recognized token
|
||||
// blocked by an IP filter, so it still counts as verified. 401 means the
|
||||
// token is unknown or expired.
|
||||
func verifyAPIToken(ctx context.Context, client *http.Client, baseURL, token string) (bool, error) {
|
||||
endpoint, err := url.JoinPath(baseURL, "/api/v1/health-json")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
}()
|
||||
|
||||
switch res.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusForbidden:
|
||||
// Token was recognized but blocked (IP filter, permission change).
|
||||
return true, nil
|
||||
case http.StatusUnauthorized:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unexpected HTTP response status %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//go:build detectors
|
||||
// +build detectors
|
||||
|
||||
package humioapitoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detector_typepb"
|
||||
)
|
||||
|
||||
// newTestScanner returns a Scanner with the cloud endpoint configured, mirroring
|
||||
// what DefaultDetectors() does at startup. Without this, Endpoints() returns
|
||||
// nothing and the verification loop never runs.
|
||||
func newTestScanner(client *http.Client) Scanner {
|
||||
s := Scanner{client: client}
|
||||
s.SetCloudEndpoint("https://cloud.us.humio.com")
|
||||
s.UseCloudEndpoint(true)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHumioAPIToken_FromChunk(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||
defer cancel()
|
||||
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors7")
|
||||
if err != nil {
|
||||
t.Fatalf("could not get test secrets from GCP: %s", err)
|
||||
}
|
||||
secret := testSecrets.MustGetField("HUMIOAPITOKEN")
|
||||
inactiveSecret := testSecrets.MustGetField("HUMIOAPITOKEN_INACTIVE")
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
data []byte
|
||||
verify bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
s Scanner
|
||||
args args
|
||||
want []detectors.Result
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "found, verified",
|
||||
s: newTestScanner(nil),
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: []byte(fmt.Sprintf("You can find a humio api token %s within", secret)),
|
||||
verify: true,
|
||||
},
|
||||
want: []detectors.Result{
|
||||
{
|
||||
DetectorType: detector_typepb.DetectorType_HumioAPIToken,
|
||||
Verified: true,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "found, real secrets, verification error due to timeout",
|
||||
s: newTestScanner(common.SaneHttpClientTimeOut(1 * time.Microsecond)),
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: []byte(fmt.Sprintf("You can find a humio api token %s within", secret)),
|
||||
verify: true,
|
||||
},
|
||||
want: func() []detectors.Result {
|
||||
r := detectors.Result{
|
||||
DetectorType: detector_typepb.DetectorType_HumioAPIToken,
|
||||
Verified: false,
|
||||
}
|
||||
r.SetVerificationError(context.DeadlineExceeded)
|
||||
return []detectors.Result{r}
|
||||
}(),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "found, real secrets, verification error due to unexpected api surface",
|
||||
s: newTestScanner(common.ConstantResponseHttpClient(500, "{}")),
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: []byte(fmt.Sprintf("You can find a humio api token %s within", secret)),
|
||||
verify: true,
|
||||
},
|
||||
want: func() []detectors.Result {
|
||||
r := detectors.Result{
|
||||
DetectorType: detector_typepb.DetectorType_HumioAPIToken,
|
||||
Verified: false,
|
||||
}
|
||||
r.SetVerificationError(fmt.Errorf("unexpected HTTP response status 500"))
|
||||
return []detectors.Result{r}
|
||||
}(),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "found, unverified",
|
||||
s: newTestScanner(nil),
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: []byte(fmt.Sprintf("You can find a humio api token %s within but not valid", inactiveSecret)),
|
||||
verify: true,
|
||||
},
|
||||
want: []detectors.Result{
|
||||
{
|
||||
DetectorType: detector_typepb.DetectorType_HumioAPIToken,
|
||||
Verified: false,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
s: newTestScanner(nil),
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
data: []byte("You cannot find the secret within"),
|
||||
verify: true,
|
||||
},
|
||||
want: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.s.FromData(tt.args.ctx, tt.args.verify, tt.args.data)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("HumioAPIToken.FromData() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if len(got[i].Raw) == 0 {
|
||||
t.Fatalf("no raw secret present: \n %+v", got[i])
|
||||
}
|
||||
gotErr := ""
|
||||
if got[i].VerificationError() != nil {
|
||||
gotErr = got[i].VerificationError().Error()
|
||||
}
|
||||
wantErr := ""
|
||||
if tt.want[i].VerificationError() != nil {
|
||||
wantErr = tt.want[i].VerificationError().Error()
|
||||
}
|
||||
if gotErr != wantErr {
|
||||
t.Fatalf("wantVerificationError = %v, verification error = %v", tt.want[i].VerificationError(), got[i].VerificationError())
|
||||
}
|
||||
}
|
||||
ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "SecretParts", "ExtraData")
|
||||
if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" {
|
||||
t.Errorf("HumioAPIToken.FromData() %s diff: (-got +want)\n%s", tt.name, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFromData(benchmark *testing.B) {
|
||||
ctx := context.Background()
|
||||
s := Scanner{}
|
||||
for name, data := range detectors.MustGetBenchmarkData() {
|
||||
benchmark.Run(name, func(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for n := 0; n < b.N; n++ {
|
||||
_, err := s.FromData(ctx, false, data)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,7 @@ import (
|
||||
hubspot_apikey_v2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/hubspot_apikey/v2"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/huggingface"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/humanity"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/humioapitoken"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/hunter"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/hybiscus"
|
||||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors/hypertrack"
|
||||
@@ -1299,6 +1300,7 @@ func buildDetectorList() []detectors.Detector {
|
||||
&hubspot_apikey_v2.Scanner{},
|
||||
&huggingface.Scanner{},
|
||||
&humanity.Scanner{},
|
||||
&humioapitoken.Scanner{},
|
||||
&hunter.Scanner{},
|
||||
&hybiscus.Scanner{},
|
||||
&hypertrack.Scanner{},
|
||||
@@ -1894,6 +1896,8 @@ func buildDetectorList() []detectors.Detector {
|
||||
return !feature.MSTeamsWebhookV2DetectorEnabled.Load()
|
||||
case *solarwindsobservability.Scanner:
|
||||
return !feature.SolarwindsDetectorEnabled.Load()
|
||||
case *humioapitoken.Scanner:
|
||||
return !feature.HumioAPITokenDetectorEnabled.Load()
|
||||
case *weightsandbiasesv2.Scanner:
|
||||
return !feature.WeightsAndBiasesV2DetectorEnabled.Load()
|
||||
default:
|
||||
|
||||
@@ -151,6 +151,7 @@ var excludedFromDefaultList = map[detector_typepb.DetectorType]struct{}{
|
||||
detector_typepb.DetectorType_NewRelicInsightsQueryKey: {},
|
||||
detector_typepb.DetectorType_NewRelicMobileAppToken: {},
|
||||
detector_typepb.DetectorType_SolarWindsObservability: {},
|
||||
detector_typepb.DetectorType_HumioAPIToken: {},
|
||||
|
||||
// Reserved / special types.
|
||||
detector_typepb.DetectorType_CustomRegex: {}, // added dynamically via engine config, not via buildDetectorList()
|
||||
|
||||
@@ -51,6 +51,7 @@ var (
|
||||
MSTeamsWebhookV2DetectorEnabled atomic.Bool
|
||||
SolarwindsDetectorEnabled atomic.Bool
|
||||
WeightsAndBiasesV2DetectorEnabled atomic.Bool
|
||||
HumioAPITokenDetectorEnabled atomic.Bool
|
||||
)
|
||||
|
||||
type AtomicString struct {
|
||||
|
||||
@@ -1125,6 +1125,7 @@ const (
|
||||
DetectorType_NewRelicInsightsQueryKey DetectorType = 1067
|
||||
DetectorType_NewRelicMobileAppToken DetectorType = 1068
|
||||
DetectorType_SolarWindsObservability DetectorType = 1069
|
||||
DetectorType_HumioAPIToken DetectorType = 1070
|
||||
)
|
||||
|
||||
// Enum value maps for DetectorType.
|
||||
@@ -2196,6 +2197,7 @@ var (
|
||||
1067: "NewRelicInsightsQueryKey",
|
||||
1068: "NewRelicMobileAppToken",
|
||||
1069: "SolarWindsObservability",
|
||||
1070: "HumioAPIToken",
|
||||
}
|
||||
DetectorType_value = map[string]int32{
|
||||
"Alibaba": 0,
|
||||
@@ -3264,6 +3266,7 @@ var (
|
||||
"NewRelicInsightsQueryKey": 1067,
|
||||
"NewRelicMobileAppToken": 1068,
|
||||
"SolarWindsObservability": 1069,
|
||||
"HumioAPIToken": 1070,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3299,7 +3302,7 @@ var File_detector_type_proto protoreflect.FileDescriptor
|
||||
var file_detector_type_proto_rawDesc = []byte{
|
||||
0x0a, 0x13, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f,
|
||||
0x74, 0x79, 0x70, 0x65, 0x2a, 0xf3, 0x8b, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
|
||||
0x74, 0x79, 0x70, 0x65, 0x2a, 0x87, 0x8c, 0x01, 0x0a, 0x0c, 0x44, 0x65, 0x74, 0x65, 0x63, 0x74,
|
||||
0x6f, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x41, 0x6c, 0x69, 0x62, 0x61, 0x62,
|
||||
0x61, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x41, 0x4d, 0x51, 0x50, 0x10, 0x01, 0x12, 0x07, 0x0a,
|
||||
0x03, 0x41, 0x57, 0x53, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x7a, 0x75, 0x72, 0x65, 0x10,
|
||||
@@ -4418,12 +4421,13 @@ var file_detector_type_proto_rawDesc = []byte{
|
||||
0x12, 0x1b, 0x0a, 0x16, 0x4e, 0x65, 0x77, 0x52, 0x65, 0x6c, 0x69, 0x63, 0x4d, 0x6f, 0x62, 0x69,
|
||||
0x6c, 0x65, 0x41, 0x70, 0x70, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xac, 0x08, 0x12, 0x1c, 0x0a,
|
||||
0x17, 0x53, 0x6f, 0x6c, 0x61, 0x72, 0x57, 0x69, 0x6e, 0x64, 0x73, 0x4f, 0x62, 0x73, 0x65, 0x72,
|
||||
0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x10, 0xad, 0x08, 0x42, 0x41, 0x5a, 0x3f, 0x67,
|
||||
0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c,
|
||||
0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72, 0x75, 0x66, 0x66, 0x6c,
|
||||
0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x62, 0x2f, 0x64,
|
||||
0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x70, 0x62, 0x62, 0x06,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x10, 0xad, 0x08, 0x12, 0x12, 0x0a, 0x0d, 0x48,
|
||||
0x75, 0x6d, 0x69, 0x6f, 0x41, 0x50, 0x49, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x10, 0xae, 0x08, 0x42,
|
||||
0x41, 0x5a, 0x3f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x74, 0x72,
|
||||
0x75, 0x66, 0x66, 0x6c, 0x65, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x2f, 0x74, 0x72,
|
||||
0x75, 0x66, 0x66, 0x6c, 0x65, 0x68, 0x6f, 0x67, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f,
|
||||
0x70, 0x62, 0x2f, 0x64, 0x65, 0x74, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x74, 0x79, 0x70, 0x65,
|
||||
0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -1071,4 +1071,5 @@ enum DetectorType {
|
||||
NewRelicInsightsQueryKey = 1067;
|
||||
NewRelicMobileAppToken = 1068;
|
||||
SolarWindsObservability = 1069;
|
||||
HumioAPIToken = 1070;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user