Skip to content
Open
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
181 changes: 181 additions & 0 deletions pkg/detectors/hashicorpvaultbatchtoken/hashicorpvaultbatchtoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package hashicorpbatchtoken

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"

regexp "github.com/wasilibs/go-re2"

"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb"
)

type Scanner struct {
client *http.Client
detectors.DefaultMultiPartCredentialProvider
detectors.EndpointSetter
}

var _ detectors.Detector = (*Scanner)(nil)
var _ detectors.EndpointCustomizer = (*Scanner)(nil)

var (
defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses

// Batch tokens: hvb.<50-300 chars>
batchTokenPat = regexp.MustCompile(
`\b(hvb\.[A-Za-z0-9_.-]{50,300})\b`,
)

vaultUrlPat = regexp.MustCompile(`(https?:\/\/[^\s\/]*\.hashicorp\.cloud(?::\d+)?)(?:\/[^\s]*)?`)
)

func (s Scanner) Keywords() []string {
return []string{"hvb."}
}

func (Scanner) CloudEndpoint() string { return "" }

func (s Scanner) Description() string {
return "This detector detects and verifies HashiCorp Vault batch tokens"
}

func (s Scanner) getClient() *http.Client {
if s.client != nil {
return s.client
}
return defaultClient
}

func (s Scanner) FromData(
ctx context.Context,
verify bool,
data []byte,
) (results []detectors.Result, err error) {

dataStr := string(data)

uniqueTokens := make(map[string]struct{})
for _, match := range batchTokenPat.FindAllStringSubmatch(dataStr, -1) {
uniqueTokens[match[1]] = struct{}{}
}

var uniqueVaultUrls = make(map[string]struct{})
for _, match := range vaultUrlPat.FindAllStringSubmatch(dataStr, -1) {
url := strings.TrimSpace(match[1])
uniqueVaultUrls[url] = struct{}{}
}

endpoints := make([]string, 0, len(uniqueVaultUrls))
for endpoint := range uniqueVaultUrls {
endpoints = append(endpoints, endpoint)
}

for _, endpoint := range s.Endpoints(endpoints...) {
for token := range uniqueTokens {
result := detectors.Result{
DetectorType: detectorspb.DetectorType_HashiCorpVaultBatchToken,
Raw: []byte(token),
RawV2: []byte(token + endpoint),
Redacted: token[:8] + "...",
}

if verify {
verified, verificationResp, verificationErr := verifyVaultToken(
ctx,
s.getClient(),
endpoint,
token,
)
result.SetVerificationError(verificationErr, token)
result.Verified = verified

if verificationResp != nil {
result.ExtraData = map[string]string{
"policies": strings.Join(verificationResp.Data.Policies, ", "),
"orphan": fmt.Sprintf("%v", verificationResp.Data.Orphan),
"renewable": fmt.Sprintf("%v", verificationResp.Data.Renewable),
"type": verificationResp.Data.Type,
"entity_id": verificationResp.Data.EntityId,
}
}
}

results = append(results, result)
}
}

return
}

type lookupResponse struct {
Data struct {
DisplayName string `json:"display_name"`
EntityId string `json:"entity_id"`
ExpireTime string `json:"expire_time"`
Orphan bool `json:"orphan"`
Policies []string `json:"policies"`
Renewable bool `json:"renewable"`
Type string `json:"type"`
}
}

func verifyVaultToken(
ctx context.Context,
client *http.Client,
baseUrl string,
token string,
) (bool, *lookupResponse, error) {
url, err := url.JoinPath(baseUrl, "/v1/auth/token/lookup-self")
if err != nil {
return false, nil, err
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
url,
http.NoBody,
)
if err != nil {
return false, nil, err
}

req.Header.Set("X-Vault-Token", token)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Vault Namespace Header Breaks Verification

High Severity

The verifyVaultToken function never sets the X-Vault-Namespace: admin HTTP header. HCP Vault Dedicated clusters (the only type of endpoint the vaultUrlPat regex can match, since it only matches *.hashicorp.cloud URLs) require this header on every API request — without it, the server returns a 403 permission denied response regardless of token validity. Since the code interprets 403 as an invalid token, every real batch token will always be reported as unverified, making the verification feature non-functional. The sibling hashicorpvaultauth detector correctly sets this header.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the docs the lookup-self endpoint that we are using doesn't require X-Vault-Namespace header and I have also verified this behaviour using postman.


res, err := client.Do(req)
if err != nil {
return false, nil, err
}
defer func() {
_, _ = io.Copy(io.Discard, res.Body)
_ = res.Body.Close()
}()

switch res.StatusCode {
case http.StatusOK:
var resp lookupResponse
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
return false, nil, err
}

return true, &resp, nil

case http.StatusForbidden, http.StatusUnauthorized:
return false, nil, nil

default:
return false, nil, fmt.Errorf(
"unexpected HTTP response status %d",
res.StatusCode,
)
}
}

func (s Scanner) Type() detectorspb.DetectorType {
return detectorspb.DetectorType_HashiCorpVaultBatchToken
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//go:build detectors
// +build detectors

package hashicorpbatchtoken

import (
"context"
"fmt"
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/trufflesecurity/trufflehog/v3/pkg/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors"
)

func TestBatchToken_FromData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

// Fetch test secrets
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors6")
if err != nil {
t.Fatalf("could not get test secrets: %s", err)
}

vaultURL := testSecrets.MustGetField("HASHICORPVAULT_CLOUD_URL")

// This token has maximum TTL of 32days (768h), so it should still be valid by the time this test runs
// but if the test fails due to an invalid token, this is the most likely culprit and the token may need to be regenerated.
// To regenerate the token run this command in vault web cli:
// write auth/token/create type=batch policies="test-policy" ttl="768h" no_parent=true
batchToken := testSecrets.MustGetField("HASHICORPVAULT_BATCH_TOKEN")

fakeToken := "hvb.fakeinvalidtokenaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

tests := []struct {
name string
input string
verify bool
wantTokens []string
wantVerified bool
wantVerificationErr bool
}{
{
name: "valid batch token with URL, verify",
input: fmt.Sprintf("%s\n%s", batchToken, vaultURL),
verify: true,
wantTokens: []string{
batchToken + vaultURL,
},
wantVerified: true,
wantVerificationErr: false,
},
{
name: "invalid batch token with URL, verify",
input: fmt.Sprintf("%s\n%s", fakeToken, vaultURL),
verify: true,
wantTokens: []string{
fakeToken + vaultURL,
},
wantVerified: false,
wantVerificationErr: false,
},
{
name: "valid batch token with URL, no verify",
input: fmt.Sprintf("%s\n%s", batchToken, vaultURL),
verify: false,
wantTokens: []string{
batchToken + vaultURL,
},
wantVerified: false,
wantVerificationErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
scanner := Scanner{}
scanner.UseFoundEndpoints(true)
scanner.UseCloudEndpoint(true)

results, err := scanner.FromData(ctx, tt.verify, []byte(tt.input))
require.NoError(t, err)

if len(results) != len(tt.wantTokens) {
t.Fatalf("expected %d results, got %d", len(tt.wantTokens), len(results))
}

for i, r := range results {
if string(r.RawV2) != tt.wantTokens[i] && string(r.Raw) != tt.wantTokens[i] {
t.Errorf("expected token %s, got %s", tt.wantTokens[i], string(r.Raw))
}

if r.Verified != tt.wantVerified {
t.Errorf("expected verified=%v, got %v", tt.wantVerified, r.Verified)
}

if (r.VerificationError() != nil) != tt.wantVerificationErr {
t.Errorf("expected verification error=%v, got %v", tt.wantVerificationErr, r.VerificationError())
}
}
})
}
}

func BenchmarkFromData(b *testing.B) {
ctx := context.Background()
s := Scanner{}

for name, data := range detectors.MustGetBenchmarkData() {
b.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)
}
}
})
}
}
Loading
Loading