-
Notifications
You must be signed in to change notification settings - Fork 2.4k
[INS-355] Added Hashicorp vault token detector #4819
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MuneebUllahKhan222
wants to merge
12
commits into
trufflesecurity:main
Choose a base branch
from
MuneebUllahKhan222:hashicorp-vault-token
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
7e028ef
added hashicorp vault token detector
MuneebUllahKhan222 659020e
added redacted field to result
MuneebUllahKhan222 1b1b9ae
Resolved bugbot comments
MuneebUllahKhan222 69399e8
fixed redaction
MuneebUllahKhan222 6db6caa
resolved bugbot comment
MuneebUllahKhan222 d36bf4a
updated keyword slice
MuneebUllahKhan222 ff9ff3c
resolved bugbot comments and updated description
MuneebUllahKhan222 390072a
Merged main and resolved conflicts
MuneebUllahKhan222 a0352e6
updated protos with detector id 1048
MuneebUllahKhan222 cb389e4
Fixed bugbot comment and added secret parts
MuneebUllahKhan222 73b021f
resolved bugbot comment
MuneebUllahKhan222 2e4adbc
fixed failing engine test
MuneebUllahKhan222 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
182 changes: 182 additions & 0 deletions
182
pkg/detectors/hashicorpvaulttoken/hashicorpvaulttoken.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| package hashicorpvaulttoken | ||
|
|
||
| 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 _ detectors.CloudProvider = (*Scanner)(nil) | ||
|
|
||
| var ( | ||
| defaultClient = detectors.DetectorHttpClientWithNoLocalAddresses | ||
|
|
||
| // Vault tokens: | ||
| // newer vault tokens are around 90-120 chars and start with hvs. (HashiCorp Vault Service token) | ||
| // legacy tokens are around 18-40 chars and start with s. | ||
| vaultTokenPat = regexp.MustCompile( | ||
| `\b(hvs\.[A-Za-z0-9_-]{90,120}|s\.[A-Za-z0-9_-]{18,40})\b`, | ||
| ) | ||
|
|
||
| vaultUrlPat = regexp.MustCompile(`(https?:\/\/[^\s\/]*\.hashicorp\.cloud(?::\d+)?)(?:\/[^\s]*)?`) | ||
|
MuneebUllahKhan222 marked this conversation as resolved.
|
||
| ) | ||
|
|
||
| func (s Scanner) Keywords() []string { | ||
| return []string{"hvs.", "s."} | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| func (Scanner) CloudEndpoint() string { return "" } | ||
|
|
||
| func (s Scanner) Description() string { | ||
| return "This detector detects and verifies HashiCorp Vault periodic tokens, standard service tokens and admin 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 vaultTokenPat.FindAllStringSubmatch(dataStr, -1) { | ||
| uniqueTokens[match[1]] = struct{}{} | ||
| } | ||
|
|
||
| var uniqueVaultUrls = make(map[string]struct{}) | ||
| for _, match := range vaultUrlPat.FindAllString(dataStr, -1) { | ||
| url := strings.TrimSpace(match) | ||
| uniqueVaultUrls[url] = struct{}{} | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| 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_HashiCorpVaultToken, | ||
| Raw: []byte(token), | ||
| RawV2: []byte(token + endpoint), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A separator between token and endpoint could be helpful here |
||
| Redacted: token[8:] + "...", | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| 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, // can be helpful in revoking the token | ||
| } | ||
| } | ||
| } | ||
|
|
||
| results = append(results, result) | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| 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) | ||
|
|
||
| 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_HashiCorpVaultToken | ||
| } | ||
121 changes: 121 additions & 0 deletions
121
pkg/detectors/hashicorpvaulttoken/hashicorpvaulttoken_integration_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| //go:build detectors | ||
| // +build detectors | ||
|
|
||
| package hashicorpvaulttoken | ||
|
|
||
| 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 TestVaultToken_FromData_Integration(t *testing.T) { | ||
| ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) | ||
| defer cancel() | ||
|
|
||
| // Fetch test secrets from TruffleHog test secret storage | ||
| testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors-vault") | ||
| if err != nil { | ||
| t.Fatalf("could not get test secrets: %s", err) | ||
| } | ||
|
|
||
| vaultURL := testSecrets.MustGetField("HASHICORPVAULT_CLOUD_URL") | ||
| // 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 policies="test-policy" ttl="768h" display_name="integration-test-token" | ||
| token := testSecrets.MustGetField("HASHICORPVAULT_TOKEN") | ||
|
|
||
| fakeToken := "hvs.CAESIDdRIXSyTRAmJ2nvTohWQOPZW4gtKBeuMCQ1amIgUcWtGigKImh2cy5wdEprMjdtZWNqRXJUeElXT0lXZ0lRZVQuV2JZVlgQ3_4Q" // invalid/unused token | ||
|
|
||
| tests := []struct { | ||
| name string | ||
| input string | ||
| verify bool | ||
| wantTokens []string | ||
| wantVerified []bool | ||
| wantVerificationErr bool | ||
| }{ | ||
| { | ||
| name: "valid token with URL, verify", | ||
| input: fmt.Sprintf("%s\n%s", token, vaultURL), | ||
| verify: true, | ||
| wantTokens: []string{ | ||
| token + vaultURL, | ||
| }, | ||
| wantVerified: []bool{true}, | ||
| wantVerificationErr: false, | ||
| }, | ||
| { | ||
| name: "invalid token with URL, verify", | ||
| input: fmt.Sprintf("%s\n%s", fakeToken, vaultURL), | ||
| verify: true, | ||
| wantTokens: []string{ | ||
| fakeToken + vaultURL, | ||
| }, | ||
| wantVerified: []bool{false}, | ||
| wantVerificationErr: false, // invalid tokens are not errors, just not verified | ||
| }, | ||
| { | ||
| name: "valid token with URL, no verify", | ||
| input: fmt.Sprintf("%s\n%s", token, vaultURL), | ||
| verify: false, | ||
| wantTokens: []string{ | ||
| token + vaultURL, | ||
| }, | ||
| wantVerified: []bool{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[i] { | ||
| t.Errorf("expected verified=%v, got %v", tt.wantVerified[i], r.Verified) | ||
| } | ||
|
|
||
| if (r.VerificationError() != nil) != tt.wantVerificationErr { | ||
| t.Errorf("expected verification error=%v, got %v", tt.wantVerificationErr, r.VerificationError()) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func BenchmarkSpectralOps_FromData(b *testing.B) { | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
|
||
| 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) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.