-
Notifications
You must be signed in to change notification settings - Fork 1.7k
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
Updated Fastly Personal Token Detector #3386
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,10 +4,10 @@ import ( | |
"context" | ||
"encoding/json" | ||
"fmt" | ||
regexp "github.com/wasilibs/go-re2" | ||
"io" | ||
"net/http" | ||
"strings" | ||
|
||
regexp "github.com/wasilibs/go-re2" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/common" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
|
@@ -31,12 +31,10 @@ func (s Scanner) Keywords() []string { | |
return []string{"fastly"} | ||
} | ||
|
||
type fastlyUserRes struct { | ||
Login string `json:"login"` | ||
Name string `json:"name"` | ||
Role string `json:"role"` | ||
TwoFactorAuthEnabled bool `json:"two_factor_auth_enabled"` | ||
Locked bool `json:"locked"` | ||
type token struct { | ||
TokenID string `json:"id"` | ||
UserID string `json:"user_id"` | ||
ExpiresAt string `json:"expires_at"` | ||
} | ||
|
||
// FromData will find and optionally verify FastlyPersonalToken secrets in a given set of bytes. | ||
|
@@ -46,44 +44,22 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result | |
matches := keyPat.FindAllStringSubmatch(dataStr, -1) | ||
|
||
for _, match := range matches { | ||
if len(match) != 2 { | ||
continue | ||
} | ||
resMatch := strings.TrimSpace(match[1]) | ||
resMatch := match[1] | ||
zricethezav marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
s1 := detectors.Result{ | ||
DetectorType: detectorspb.DetectorType_FastlyPersonalToken, | ||
Raw: []byte(resMatch), | ||
} | ||
|
||
if verify { | ||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.fastly.com/current_user", nil) | ||
if err != nil { | ||
continue | ||
extraData, verified, verificationErr := verifyFastlyApiToken(ctx, resMatch) | ||
s1.Verified = verified | ||
if extraData != nil { | ||
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. Nit: IMO nil check for extraData is unneccesary. |
||
s1.ExtraData = extraData | ||
} | ||
req.Header.Add("Fastly-Key", resMatch) | ||
res, err := client.Do(req) | ||
if err == nil { | ||
bodyBytes, err := io.ReadAll(res.Body) | ||
if err != nil { | ||
continue | ||
} | ||
defer res.Body.Close() | ||
if res.StatusCode >= 200 && res.StatusCode < 300 { | ||
var userRes fastlyUserRes | ||
err = json.Unmarshal(bodyBytes, &userRes) | ||
if err != nil { | ||
continue | ||
} | ||
s1.Verified = true | ||
s1.ExtraData = map[string]string{ | ||
"username": userRes.Login, | ||
"name": userRes.Name, | ||
"role": userRes.Role, | ||
"locked": fmt.Sprintf("%t", userRes.Locked), | ||
"two_factor_auth_enabled": fmt.Sprintf("%t", userRes.TwoFactorAuthEnabled), | ||
} | ||
} | ||
|
||
if verificationErr != nil { | ||
zricethezav marked this conversation as resolved.
Show resolved
Hide resolved
|
||
s1.SetVerificationError(verificationErr) | ||
} | ||
} | ||
|
||
|
@@ -100,3 +76,48 @@ func (s Scanner) Type() detectorspb.DetectorType { | |
func (s Scanner) Description() string { | ||
return "Fastly is a content delivery network (CDN) and cloud service provider. Fastly personal tokens can be used to authenticate API requests to Fastly services." | ||
} | ||
|
||
func verifyFastlyApiToken(ctx context.Context, apiToken string) (map[string]string, bool, error) { | ||
// api-docs: https://www.fastly.com/documentation/reference/api/auth-tokens/user/ | ||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.fastly.com/tokens/self", nil) | ||
if err != nil { | ||
return nil, false, err | ||
} | ||
|
||
// add api key in the header | ||
req.Header.Add("Fastly-Key", apiToken) | ||
resp, err := client.Do(req) | ||
if err != nil { | ||
return nil, false, err | ||
} | ||
defer func() { | ||
_, _ = io.Copy(io.Discard, resp.Body) | ||
_ = resp.Body.Close() | ||
}() | ||
|
||
switch resp.StatusCode { | ||
case http.StatusOK: | ||
var self token | ||
if err = json.NewDecoder(resp.Body).Decode(&self); err != nil { | ||
return nil, false, err | ||
} | ||
|
||
// capture token details in the map | ||
extraData := map[string]string{ | ||
// token id is the alphanumeric string uniquely identifying a token | ||
"token_id": self.TokenID, | ||
// user id is the alphanumeric string uniquely identifying the user | ||
"user_id": self.UserID, | ||
// expires at is time-stamp (UTC) of when the token will expire. | ||
"token_expires_at": self.ExpiresAt, | ||
zricethezav marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
return extraData, true, nil | ||
case http.StatusUnauthorized, http.StatusForbidden: | ||
// as per fastly documentation: An HTTP 401 response is returned on an expired token. An HTTP 403 response is returned on an invalid access token. | ||
return nil, false, nil | ||
default: | ||
return nil, false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) | ||
|
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,120 @@ | ||
//go:build detectors | ||
// +build detectors | ||
|
||
package fastlypersonaltoken | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
"testing" | ||
"time" | ||
|
||
"github.com/kylelemons/godebug/pretty" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/detectors" | ||
|
||
"github.com/trufflesecurity/trufflehog/v3/pkg/common" | ||
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" | ||
) | ||
|
||
func TestFastlyPersonalToken_FromChunk(t *testing.T) { | ||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) | ||
defer cancel() | ||
testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3") | ||
if err != nil { | ||
t.Fatalf("could not get test secrets from GCP: %s", err) | ||
} | ||
secret := testSecrets.MustGetField("FASTLYPERSONALTOKEN_TOKEN") | ||
inactiveSecret := testSecrets.MustGetField("FASTLYPERSONALTOKEN_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: Scanner{}, | ||
args: args{ | ||
ctx: context.Background(), | ||
data: []byte(fmt.Sprintf("You can find a fastlypersonaltoken secret %s within", secret)), | ||
verify: true, | ||
}, | ||
want: []detectors.Result{ | ||
{ | ||
DetectorType: detectorspb.DetectorType_FastlyPersonalToken, | ||
Verified: true, | ||
}, | ||
}, | ||
wantErr: false, | ||
}, | ||
{ | ||
name: "found, unverified", | ||
s: Scanner{}, | ||
args: args{ | ||
ctx: context.Background(), | ||
data: []byte(fmt.Sprintf("You can find a fastlypersonaltoken secret %s within but not valid", inactiveSecret)), // the secret would satisfy the regex but not pass validation | ||
verify: true, | ||
}, | ||
want: []detectors.Result{ | ||
{ | ||
DetectorType: detectorspb.DetectorType_FastlyPersonalToken, | ||
Verified: false, | ||
}, | ||
}, | ||
wantErr: false, | ||
}, | ||
{ | ||
name: "not found", | ||
s: Scanner{}, | ||
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) { | ||
s := Scanner{} | ||
got, err := s.FromData(tt.args.ctx, tt.args.verify, tt.args.data) | ||
if (err != nil) != tt.wantErr { | ||
t.Errorf("FastlyPersonalToken.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]) | ||
} | ||
got[i].Raw = nil | ||
} | ||
if diff := pretty.Compare(got, tt.want); diff != "" { | ||
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. I have doubt that this test will fail due to extraData diff (nil vs Map) in valid & verified cases. 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. Yeah, good catch! I'll update these test cases. |
||
t.Errorf("FastlyPersonalToken.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) | ||
} | ||
} | ||
}) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kashifkhan0771 Response also contains scope of that token. My suggestion would be to include it in the extraData.