Skip to content

Commit

Permalink
feat(dockerhub): update logic
Browse files Browse the repository at this point in the history
  • Loading branch information
rgmz committed Jan 5, 2024
1 parent 241e153 commit 312161d
Showing 1 changed file with 121 additions and 41 deletions.
162 changes: 121 additions & 41 deletions pkg/detectors/dockerhub/dockerhub.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,34 @@ package dockerhub

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

"github.com/golang-jwt/jwt/v4"

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

type Scanner struct{}
type Scanner struct {
client *http.Client
}

// Ensure the Scanner satisfies the interface at compile time.
var _ detectors.Detector = (*Scanner)(nil)

var (
client = common.SaneHttpClient()

// Can use email or username for login.
usernamePat = regexp.MustCompile(`(?im)(?:user|usr|-u)\S{0,40}?[:=\s]{1,3}[ '"=]?([a-zA-Z0-9]{4,40})\b`)
emailPat = regexp.MustCompile(common.EmailPattern)
emailPat = regexp.MustCompile(`(` + common.EmailPattern + `)`)

// Can use password or personal access token (PAT) for login, but this scanner will only check for PATs.
accessTokenPat = regexp.MustCompile(`\bdckr_pat_([a-zA-Z0-9_-]){27}\b`)
accessTokenPat = regexp.MustCompile(`\b(dckr_pat_[a-zA-Z0-9_-]{27})(?:[^a-zA-Z0-9_-]|\z)`)
)

// Keywords are used for efficiently pre-filtering chunks.
Expand All @@ -39,55 +42,132 @@ func (s Scanner) Keywords() []string {
func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (results []detectors.Result, err error) {
dataStr := string(data)

emailMatches := emailPat.FindAllString(dataStr, -1)
dataStr = emailPat.ReplaceAllString(dataStr, "")
usernameMatches := usernamePat.FindAllStringSubmatch(dataStr, -1)

accessTokenMatches := accessTokenPat.FindAllString(dataStr, -1)
// Deduplicate results.
tokens := make(map[string]struct{})
for _, matches := range accessTokenPat.FindAllStringSubmatch(dataStr, -1) {
tokens[matches[1]] = struct{}{}
}
if len(tokens) == 0 {
return
}
usernames := make(map[string]struct{})
for _, matches := range usernamePat.FindAllStringSubmatch(dataStr, -1) {
usernames[matches[1]] = struct{}{}
}
for _, matches := range emailPat.FindAllStringSubmatch(dataStr, -1) {
usernames[matches[1]] = struct{}{}
}

userMatches := emailMatches
for _, usernameMatch := range usernameMatches {
if len(usernameMatch) > 1 {
userMatches = append(userMatches, usernameMatch[1])
// Process results.
for token := range tokens {
s1 := detectors.Result{
DetectorType: s.Type(),
Raw: []byte(token),
}
}

for _, resUserMatch := range userMatches {
for _, resAccessTokenMatch := range accessTokenMatches {
for username := range usernames {
s1.RawV2 = []byte(fmt.Sprintf("%s:%s", username, token))

s1 := detectors.Result{
DetectorType: detectorspb.DetectorType_Dockerhub,
Raw: []byte(fmt.Sprintf("%s: %s", resUserMatch, resAccessTokenMatch)),
if verify {
if s.client == nil {
s.client = common.SaneHttpClient()
}

isVerified, extraData, verificationErr := s.verifyMatch(ctx, username, token)
s1.Verified = isVerified
s1.ExtraData = extraData
s1.SetVerificationError(verificationErr)
}

if verify {
payload := strings.NewReader(fmt.Sprintf(`{"username": "%s", "password": "%s"}`, resUserMatch, resAccessTokenMatch))
results = append(results, s1)

req, err := http.NewRequestWithContext(ctx, "GET", "https://hub.docker.com/v2/users/login", payload)
if err != nil {
continue
}
req.Header.Add("Content-Type", "application/json")
res, err := client.Do(req)
if err == nil {
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
continue
}

// Valid credentials can still return a 401 status code if 2FA is enabled
if (res.StatusCode >= 200 && res.StatusCode < 300) || (res.StatusCode == 401 && strings.Contains(string(body), "login_2fa_token")) {
s1.Verified = true
}
}
if s1.Verified {
break
}
}

// PAT matches without usernames cannot be verified but might still be useful.
if len(usernames) == 0 {
results = append(results, s1)
}
}
return
}

func (s Scanner) verifyMatch(ctx context.Context, username string, password string) (bool, map[string]string, error) {
payload := strings.NewReader(fmt.Sprintf(`{"username": "%s", "password": "%s"}`, username, password))

req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://hub.docker.com/v2/users/login", payload)
if err != nil {
return false, nil, err
}

req.Header.Add("Content-Type", "application/json")
res, err := s.client.Do(req)
if err != nil {
return false, nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return false, nil, err
}

if res.StatusCode == http.StatusOK {
var tokenRes tokenResponse
if err := json.Unmarshal(body, &tokenRes); (err != nil || tokenRes == tokenResponse{}) {
return false, nil, err
}

parser := jwt.NewParser()
token, _, err := parser.ParseUnverified(tokenRes.Token, &hubJwtClaims{})
if err != nil {
return true, nil, err
}

if claims, ok := token.Claims.(*hubJwtClaims); ok {
extraData := map[string]string{
"Hub_username": username,
"Hub_email": claims.HubClaims.Email,
"Hub_scope": claims.Scope,
}
return true, extraData, nil
}
return true, nil, nil
} else if res.StatusCode == http.StatusUnauthorized {
// Valid credentials can still return a 401 status code if 2FA is enabled
var mfaRes mfaRequiredResponse
if err := json.Unmarshal(body, &mfaRes); err != nil || mfaRes.MfaToken == "" {
return false, nil, nil
}

extraData := map[string]string{
"Hub_username": username,
"2fa_required": "true",
}
return true, extraData, nil
} else {
return false, nil, fmt.Errorf("unexpected response status %d", res.StatusCode)
}
}

type tokenResponse struct {
Token string `json:"token"`
}

type userClaims struct {
Username string `json:"username"`
Email string `json:"email"`
}

type hubJwtClaims struct {
Scope string `json:"scope"`
HubClaims userClaims `json:"https://hub.docker.com"` // not sure why this is a key, further investigation required.
jwt.RegisteredClaims
}

return results, nil
type mfaRequiredResponse struct {
MfaToken string `json:"login_2fa_token"`
}

func (s Scanner) Type() detectorspb.DetectorType {
Expand Down

0 comments on commit 312161d

Please sign in to comment.