From b10342e5da776de881814eb3723f2843753cb190 Mon Sep 17 00:00:00 2001 From: Kashif Khan <70996046+kashifkhan0771@users.noreply.github.com> Date: Tue, 11 Feb 2025 22:31:29 +0500 Subject: [PATCH 1/4] dockerhub analyzer (#3861) * dockerhub analyzer * fixed linter * added test cases * resolved comments --- pkg/analyzer/analyzers/analyzers.go | 2 + pkg/analyzer/analyzers/dockerhub/dockerhub.go | 192 ++++++++++++++++++ .../analyzers/dockerhub/dockerhub_test.go | 88 ++++++++ pkg/analyzer/analyzers/dockerhub/helper.go | 146 +++++++++++++ .../analyzers/dockerhub/permissions.go | 76 +++++++ .../analyzers/dockerhub/permissions.yaml | 5 + pkg/analyzer/analyzers/dockerhub/requests.go | 115 +++++++++++ .../analyzers/dockerhub/result_output.json | 43 ++++ pkg/analyzer/cli.go | 3 + pkg/detectors/dockerhub/v1/dockerhub.go | 6 + pkg/detectors/dockerhub/v2/dockerhub.go | 6 + .../v2/dockerhub_integration_test.go | 9 + pkg/tui/pages/analyze_form/analyze_form.go | 11 + 13 files changed, 702 insertions(+) create mode 100644 pkg/analyzer/analyzers/dockerhub/dockerhub.go create mode 100644 pkg/analyzer/analyzers/dockerhub/dockerhub_test.go create mode 100644 pkg/analyzer/analyzers/dockerhub/helper.go create mode 100644 pkg/analyzer/analyzers/dockerhub/permissions.go create mode 100644 pkg/analyzer/analyzers/dockerhub/permissions.yaml create mode 100644 pkg/analyzer/analyzers/dockerhub/requests.go create mode 100644 pkg/analyzer/analyzers/dockerhub/result_output.json diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index c58c25130e86..168f9bd83a9b 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -63,6 +63,7 @@ const ( AnalyzerTypeAirbrake AnalyzerTypeAsana AnalyzerTypeBitbucket + AnalyzerTypeDockerHub AnalyzerTypeGitHub AnalyzerTypeGitLab AnalyzerTypeHuggingFace @@ -90,6 +91,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{ AnalyzerTypeAirbrake: "Airbrake", AnalyzerTypeAsana: "Asana", AnalyzerTypeBitbucket: "Bitbucket", + AnalyzerTypeDockerHub: "DockerHub", AnalyzerTypeGitHub: "GitHub", AnalyzerTypeGitLab: "GitLab", AnalyzerTypeHuggingFace: "HuggingFace", diff --git a/pkg/analyzer/analyzers/dockerhub/dockerhub.go b/pkg/analyzer/analyzers/dockerhub/dockerhub.go new file mode 100644 index 000000000000..a938332f63a0 --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/dockerhub.go @@ -0,0 +1,192 @@ +//go:generate generate_permissions permissions.yaml permissions.go elevenlabs +package dockerhub + +import ( + "errors" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +var _ analyzers.Analyzer = (*Analyzer)(nil) + +type Analyzer struct { + Cfg *config.Config +} + +// SecretInfo hold the information about the token generated from username and pat +type SecretInfo struct { + User User + Valid bool + Reference string + Permissions []string + Repositories []Repository + ExpiresIn string + Misc map[string]string +} + +// User hold the information about user to whom the personal access token belongs +type User struct { + ID string + Username string + Email string +} + +// Repository hold information about each repository the user can access +type Repository struct { + ID string + Name string + Type string + IsPrivate bool + StarCount int + PullCount int +} + +func (a Analyzer) Type() analyzers.AnalyzerType { + return analyzers.AnalyzerTypeDockerHub +} + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + username, exist := credInfo["username"] + if !exist { + return nil, errors.New("username not found in the credentials info") + } + + pat, exist := credInfo["pat"] + if !exist { + return nil, errors.New("personal access token(PAT) not found in the credentials info") + } + + info, err := AnalyzePermissions(a.Cfg, username, pat) + if err != nil { + return nil, err + } + + return secretInfoToAnalyzerResult(info), nil +} + +// AnalyzePermissions will collect all the scopes assigned to token along with resource it can access +func AnalyzePermissions(cfg *config.Config, username, pat string) (*SecretInfo, error) { + // create the http client + client := analyzers.NewAnalyzeClientUnrestricted(cfg) // `/user/login` is a non-safe request + + var secretInfo = &SecretInfo{} + + // try to login and get jwt token + token, err := login(client, username, pat) + if err != nil { + return nil, err + } + + if err := decodeTokenToSecretInfo(token, secretInfo); err != nil { + return nil, err + } + + // fetch repositories using the jwt token and translate them to secret info + if err := fetchRepositories(client, username, token, secretInfo); err != nil { + return nil, err + } + + // return secret info + return secretInfo, nil +} + +func AnalyzeAndPrintPermissions(cfg *config.Config, username, pat string) { + info, err := AnalyzePermissions(cfg, username, pat) + if err != nil { + // just print the error in cli and continue as a partial success + color.Red("[x] Error : %s", err.Error()) + } + + if info == nil { + color.Red("[x] Error : %s", "No information found") + return + } + + if info.Valid { + color.Green("[!] Valid DockerHub Credentials\n\n") + // print user information + printUser(info.User) + // print permissions + printPermissions(info.Permissions) + // print repositories + printRepositories(info.Repositories) + + color.Yellow("\n[i] Expires: %s", info.ExpiresIn) + } +} + +// secretInfoToAnalyzerResult translate secret info to Analyzer Result +func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { + if info == nil { + return nil + } + + result := analyzers.AnalyzerResult{ + AnalyzerType: analyzers.AnalyzerTypeDockerHub, + Metadata: map[string]any{"Valid_Key": info.Valid}, + Bindings: make([]analyzers.Binding, len(info.Repositories)), + } + + // extract information to create bindings and append to result bindings + for _, repo := range info.Repositories { + binding := analyzers.Binding{ + Resource: analyzers.Resource{ + Name: repo.Name, + FullyQualifiedName: repo.ID, + Type: repo.Type, + Metadata: map[string]any{ + "is_private": repo.IsPrivate, + "pull_count": repo.PullCount, + "star_count": repo.StarCount, + }, + }, + Permission: analyzers.Permission{ + // as all permissions are against repo, we assign the highest available permission + Value: assignHighestPermission(info.Permissions), + }, + } + + result.Bindings = append(result.Bindings, binding) + } + + return &result +} + +// cli print functions +func printUser(user User) { + color.Green("\n[i] User:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"ID", "Username", "Email"}) + t.AppendRow(table.Row{color.GreenString(user.ID), color.GreenString(user.Username), color.GreenString(user.Email)}) + t.Render() +} + +func printPermissions(permissions []string) { + color.Yellow("[i] Permissions:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Permission"}) + for _, permission := range permissions { + t.AppendRow(table.Row{color.GreenString(permission)}) + } + t.Render() +} + +func printRepositories(repos []Repository) { + color.Green("\n[i] Repositories:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Type", "ID(username/repo/repo_type/repo_name)", "Name", "Is Private", "Pull Count", "Star Count"}) + for _, repo := range repos { + t.AppendRow(table.Row{color.GreenString(repo.Type), color.GreenString(repo.ID), color.GreenString(repo.Name), + color.GreenString("%t", repo.IsPrivate), color.GreenString("%d", repo.PullCount), color.GreenString("%d", repo.StarCount)}) + } + t.Render() +} diff --git a/pkg/analyzer/analyzers/dockerhub/dockerhub_test.go b/pkg/analyzer/analyzers/dockerhub/dockerhub_test.go new file mode 100644 index 000000000000..6cc37749c0ef --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/dockerhub_test.go @@ -0,0 +1,88 @@ +package dockerhub + +import ( + _ "embed" + "encoding/json" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +//go:embed result_output.json +var expectedOutput []byte + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors4") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + username := testSecrets.MustGetField("DOCKERHUB_USERNAME") + pat := testSecrets.MustGetField("DOCKERHUB_PAT") + + tests := []struct { + name string + username string + pat string + want []byte // JSON string + wantErr bool + }{ + { + name: "valid dockerhub credentials", + username: username, + pat: pat, + want: expectedOutput, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{Cfg: &config.Config{}} + got, err := a.Analyze(ctx, map[string]string{"username": tt.username, "pat": tt.pat}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented, wantIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + wantIndented, err = json.MarshalIndent(wantObj, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} diff --git a/pkg/analyzer/analyzers/dockerhub/helper.go b/pkg/analyzer/analyzers/dockerhub/helper.go new file mode 100644 index 000000000000..bb2f3d81ce24 --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/helper.go @@ -0,0 +1,146 @@ +package dockerhub + +import ( + "errors" + "fmt" + "sort" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// permission hierarchy - always keep from highest permission to lowest +var permissionHierarchy = []string{"repo:admin", "repo:write", "repo:read", "repo:public_read"} + +// precompute a ranking map for the ranking approach. +// lower index means higher permission. +var permissionRank = func() map[string]int { + rank := make(map[string]int, len(permissionHierarchy)) + // loop over permissions hierarchy to assign index to each permission + // as hierarchy start from highest to lowest, the 0 index will be assigned to highest possible permission and n will be lowest possible permission + for i, perm := range permissionHierarchy { + rank[perm] = i + } + + // return the rank map with indexed permissions + return rank +}() + +// decodeTokenToSecretInfo decode the jwt token and add the information to secret info +func decodeTokenToSecretInfo(jwtToken string, secretInfo *SecretInfo) error { + type userClaims struct { + ID string `json:"uuid"` + Username string `json:"username"` + Email string `json:"email"` + } + + type hubJwtClaims struct { + Scope string `json:"scope"` + HubClaims userClaims `json:"https://hub.docker.com"` + ExpiresIn int `json:"exp"` + jwt.RegisteredClaims + } + + parser := jwt.NewParser() + token, _, err := parser.ParseUnverified(jwtToken, &hubJwtClaims{}) + if err != nil { + return err + } + + if claims, ok := token.Claims.(*hubJwtClaims); ok { + secretInfo.User = User{ + ID: claims.HubClaims.ID, + Username: claims.HubClaims.Username, + Email: claims.HubClaims.Email, + } + + secretInfo.ExpiresIn = humandReadableTime(claims.ExpiresIn) + + secretInfo.Permissions = append(secretInfo.Permissions, claims.Scope) + secretInfo.Valid = true + + return nil + } + + return errors.New("failed to parse claims") +} + +// repositoriesToSecretInfo translate repositories to secretInfo after sorting them +func repositoriesToSecretInfo(username string, repos *RepositoriesResponse, secretInfo *SecretInfo) { + // sort the repositories first + sortRepositories(repos) + + for _, repo := range repos.Result { + secretInfo.Repositories = append(secretInfo.Repositories, Repository{ + // as repositories does not have a unique key, we make one by combining multiple fields + ID: fmt.Sprintf("%s/repo/%s/%s", username, repo.Type, repo.Name), // e.g: user123/repo/image/repo1 + Name: repo.Name, + Type: repo.Type, + IsPrivate: repo.IsPrivate, + StarCount: repo.StarCount, + PullCount: repo.PullCount, + }) + } +} + +/* +sortRepositories sort the repositories as following + +private: + - pullcount(descending) + - starcount(descending) + +public: + - pullcount(descending) + - starcount(descending) +*/ +func sortRepositories(repos *RepositoriesResponse) { + sort.SliceStable(repos.Result, func(i, j int) bool { + a, b := repos.Result[i], repos.Result[j] + + // prioritize private repositories over public + if a.IsPrivate != b.IsPrivate { + return a.IsPrivate + } + + // sort by Pull Count (descending) + if a.PullCount != b.PullCount { + return a.PullCount > b.PullCount + } + + // sort by Star Count (descending) + return a.StarCount > b.StarCount + }) +} + +// assignHighestPermission selects the highest available permission +func assignHighestPermission(permissions []string) string { + bestRank := len(permissionHierarchy) + bestPerm := "" + for _, perm := range permissions { + // check in indexes permissions + if rank, ok := permissionRank[perm]; ok { + // early exit if highest permission is found. + if rank == 0 { + return perm + } + + if rank < bestRank { + bestRank = rank + bestPerm = perm + } + } + } + + return bestPerm + +} + +// humandReadableTime converts seconds to days, hours, minutes, or seconds based on the value +func humandReadableTime(seconds int) string { + // Convert Unix timestamp to time.Time object + t := time.Unix(int64(seconds), 0) + + // Format the time as "March 2" (Month Day format) + return t.Format("January 2, 2006") +} diff --git a/pkg/analyzer/analyzers/dockerhub/permissions.go b/pkg/analyzer/analyzers/dockerhub/permissions.go new file mode 100644 index 000000000000..d012c5abb793 --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/permissions.go @@ -0,0 +1,76 @@ +// Code generated by go generate; DO NOT EDIT. +package dockerhub + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + RepoRead Permission = iota + RepoWrite Permission = iota + RepoAdmin Permission = iota + RepoPublicRead Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + RepoRead: "repo:read", + RepoWrite: "repo:write", + RepoAdmin: "repo:admin", + RepoPublicRead: "repo:public_read", + } + + StringToPermission = map[string]Permission{ + "repo:read": RepoRead, + "repo:write": RepoWrite, + "repo:admin": RepoAdmin, + "repo:public_read": RepoPublicRead, + } + + PermissionIDs = map[Permission]int{ + RepoRead: 1, + RepoWrite: 2, + RepoAdmin: 3, + RepoPublicRead: 4, + } + + IdToPermission = map[int]Permission{ + 1: RepoRead, + 2: RepoWrite, + 3: RepoAdmin, + 4: RepoPublicRead, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := PermissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := PermissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := StringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := IdToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/dockerhub/permissions.yaml b/pkg/analyzer/analyzers/dockerhub/permissions.yaml new file mode 100644 index 000000000000..ca6cafc039be --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/permissions.yaml @@ -0,0 +1,5 @@ +permissions: +- repo:read +- repo:write +- repo:admin +- repo:public_read diff --git a/pkg/analyzer/analyzers/dockerhub/requests.go b/pkg/analyzer/analyzers/dockerhub/requests.go new file mode 100644 index 000000000000..17d95e6e8fca --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/requests.go @@ -0,0 +1,115 @@ +package dockerhub + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// LoginResponse is the successful response from the /login API +type LoginResponse struct { + Token string `json:"token"` +} + +// ErrorLoginResponse is the error response from the /login API +type ErrorLoginResponse struct { + Detail string `json:"detail"` + Login2FAToken string `json:"login_2fa_token"` // if login require 2FA authentication +} + +// RepositoriesResponse is the /repositories/ response +type RepositoriesResponse struct { + Result []struct { + Name string `json:"name"` + Type string `json:"repository_type"` + IsPrivate bool `json:"is_private"` + StarCount int `json:"star_count"` + PullCount int `json:"pull_count"` + } `json:"results"` +} + +// login call the /login api with username and jwt token and if successful retrieve the token string and return +func login(client *http.Client, username, pat string) (string, error) { + payload := strings.NewReader(fmt.Sprintf(`{"username": "%s", "password": "%s"}`, username, pat)) + + req, err := http.NewRequest(http.MethodPost, "https://hub.docker.com/v2/users/login", payload) + if err != nil { + return "", err + } + + req.Header.Add("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + switch resp.StatusCode { + case http.StatusOK: + var token LoginResponse + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return "", err + } + + return token.Token, nil + case http.StatusUnauthorized: + var errorLogin ErrorLoginResponse + if err := json.NewDecoder(resp.Body).Decode(&errorLogin); err != nil { + return "", err + } + + if errorLogin.Login2FAToken != "" { + // TODO: handle it more appropriately + return "", errors.New("valid credentials; account require 2fa authentication") + } + + return "", errors.New(errorLogin.Detail) + default: + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } +} + +// fetchRepositories call /repositories/ API +func fetchRepositories(client *http.Client, username, token string, secretInfo *SecretInfo) error { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://hub.docker.com/v2/repositories/%s", username), http.NoBody) + if err != nil { + return err + } + + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + switch resp.StatusCode { + case http.StatusOK: + var repositories RepositoriesResponse + + if err := json.NewDecoder(resp.Body).Decode(&repositories); err != nil { + return err + } + + // translate repositories response to secretInfo + repositoriesToSecretInfo(username, &repositories, secretInfo) + + return nil + case http.StatusUnauthorized, http.StatusForbidden: + // the token is valid and this shall never happen because the least scope a token can have is repo:public_read. + return nil + default: + return fmt.Errorf("unexpected status code: %d; while fetching repositories information", resp.StatusCode) + + } +} diff --git a/pkg/analyzer/analyzers/dockerhub/result_output.json b/pkg/analyzer/analyzers/dockerhub/result_output.json new file mode 100644 index 000000000000..eb8d178dd3b1 --- /dev/null +++ b/pkg/analyzer/analyzers/dockerhub/result_output.json @@ -0,0 +1,43 @@ +{ + "AnalyzerType": 4, + "Bindings": [ + { + "Resource": { + "Name": "test-private", + "FullyQualifiedName": "truffledockerman/repo/image/test-private", + "Type": "image", + "Metadata": { + "is_private": true, + "pull_count": 0, + "star_count": 0 + }, + "Parent": null + }, + "Permission": { + "Value": "repo:admin", + "Parent": null + } + }, + { + "Resource": { + "Name": "test", + "FullyQualifiedName": "truffledockerman/repo/image/test", + "Type": "image", + "Metadata": { + "is_private": false, + "pull_count": 0, + "star_count": 0 + }, + "Parent": null + }, + "Permission": { + "Value": "repo:admin", + "Parent": null + } + } + ], + "UnboundedResources": null, + "Metadata": { + "Valid_Key": true + } +} \ No newline at end of file diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index f6c8b5977505..cc1e12170e3f 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -7,6 +7,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airbrake" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/asana" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/bitbucket" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/dockerhub" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/gitlab" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/huggingface" @@ -84,5 +85,7 @@ func Run(keyType string, secretInfo SecretInfo) { opsgenie.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) case "privatekey": privatekey.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "dockerhub": + dockerhub.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["username"], secretInfo.Parts["pat"]) } } diff --git a/pkg/detectors/dockerhub/v1/dockerhub.go b/pkg/detectors/dockerhub/v1/dockerhub.go index f2f2b807a80f..02c9ce800d7e 100644 --- a/pkg/detectors/dockerhub/v1/dockerhub.go +++ b/pkg/detectors/dockerhub/v1/dockerhub.go @@ -81,6 +81,12 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.Verified = isVerified s1.ExtraData = extraData s1.SetVerificationError(verificationErr) + if s1.Verified { + s1.AnalysisInfo = map[string]string{ + "username": username, + "pat": token, + } + } } results = append(results, s1) diff --git a/pkg/detectors/dockerhub/v2/dockerhub.go b/pkg/detectors/dockerhub/v2/dockerhub.go index 9ed36886df67..a108c358c034 100644 --- a/pkg/detectors/dockerhub/v2/dockerhub.go +++ b/pkg/detectors/dockerhub/v2/dockerhub.go @@ -81,6 +81,12 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.Verified = isVerified s1.ExtraData = extraData s1.SetVerificationError(verificationErr) + if s1.Verified { + s1.AnalysisInfo = map[string]string{ + "username": username, + "pat": token, + } + } } results = append(results, s1) diff --git a/pkg/detectors/dockerhub/v2/dockerhub_integration_test.go b/pkg/detectors/dockerhub/v2/dockerhub_integration_test.go index 5f3526fc0762..9b59e257058e 100644 --- a/pkg/detectors/dockerhub/v2/dockerhub_integration_test.go +++ b/pkg/detectors/dockerhub/v2/dockerhub_integration_test.go @@ -6,6 +6,7 @@ package dockerhub import ( "context" "fmt" + "strings" "testing" "time" @@ -53,6 +54,10 @@ func TestDockerhub_FromChunk(t *testing.T) { { DetectorType: detectorspb.DetectorType_Dockerhub, Verified: true, + AnalysisInfo: map[string]string{ + "username": username, + "pat": pat, + }, }, }, wantErr: false, @@ -69,6 +74,10 @@ func TestDockerhub_FromChunk(t *testing.T) { { DetectorType: detectorspb.DetectorType_Dockerhub, Verified: true, + AnalysisInfo: map[string]string{ + "username": strings.Split(email, "-")[0], + "pat": pat, + }, }, }, wantErr: false, diff --git a/pkg/tui/pages/analyze_form/analyze_form.go b/pkg/tui/pages/analyze_form/analyze_form.go index d5c0e0656b59..a21a0876a61f 100644 --- a/pkg/tui/pages/analyze_form/analyze_form.go +++ b/pkg/tui/pages/analyze_form/analyze_form.go @@ -57,6 +57,17 @@ func New(c common.Common, keyType string) *AnalyzeForm { Key: "url", Required: true, }} + case "dockerhub": + inputs = []textinputs.InputConfig{{ + Label: "Username", + Key: "username", + Required: true, + }, { + Label: "Token(PAT)", + Key: "pat", + Required: true, + RedactInput: true, + }} default: inputs = []textinputs.InputConfig{{ Label: "Secret", From 985eb75a4692d11027308fb73541b55cbad18e2e Mon Sep 17 00:00:00 2001 From: Abdul Basit Date: Tue, 11 Feb 2025 22:39:54 +0500 Subject: [PATCH 2/4] [fix] False Positive Verification in Auth0oauth Detectors (#3901) ### Description: This PR addresses an issue where a buggy verification process was incorrectly marking false-positive credentials as verified. The following cases are now handled properly: - Malformed `authorization_code` Request: If an invalid authorization_code request is sent for verification, the API responds with a 403 Forbidden status and an invalid_grant error code. Fix: These credentials will now be marked as verified in this case. - Unauthorized Client: If the credentials do not have permission to make an authorization_code request, the API returns a 403 Forbidden status with the unauthorized_client error code. Fix: No change in behavior; this case continues to be handled correctly. - Invalid Domain: If the provided domain is not valid, the API returns a 404 Not Found status. Fix: These credentials will now be correctly marked as unverified. - Invalid ID/Secret: If the client ID or secret is invalid, the API responds with a 401 Unauthorized status. Fix: These credentials will now be correctly marked as unverified. This PR ensures a more accurate verification process and reduces false positives. Here is the results of modified test results: ![image](https://github.com/user-attachments/assets/51eb6498-39da-4c6c-aa90-224786fb6518) ### Checklist: * [ ] Tests passing (`make test-community`)? * [x] Lint passing (`make lint` this requires [golangci-lint](https://golangci-lint.run/welcome/install/#local-installation))? --- pkg/detectors/auth0oauth/auth0oauth.go | 102 ++++++++++++------ .../auth0oauth_integeration_test.go | 46 +++++++- pkg/engine/defaults/defaults.go | 3 +- 3 files changed, 112 insertions(+), 39 deletions(-) diff --git a/pkg/detectors/auth0oauth/auth0oauth.go b/pkg/detectors/auth0oauth/auth0oauth.go index b6c074b9b489..543fa060be8e 100644 --- a/pkg/detectors/auth0oauth/auth0oauth.go +++ b/pkg/detectors/auth0oauth/auth0oauth.go @@ -2,6 +2,7 @@ package auth0oauth import ( "context" + "fmt" "io" "net/http" "net/url" @@ -15,13 +16,14 @@ import ( type Scanner struct { detectors.DefaultMultiPartCredentialProvider + client *http.Client } // Ensure the Scanner satisfies the interface at compile time. var _ detectors.Detector = (*Scanner)(nil) var ( - client = detectors.DetectorHttpClientWithLocalAddresses + defaultClient = detectors.DetectorHttpClientWithLocalAddresses clientIdPat = regexp.MustCompile(detectors.PrefixRegex([]string{"auth0"}) + `\b([a-zA-Z0-9_-]{32,60})\b`) clientSecretPat = regexp.MustCompile(`\b([a-zA-Z0-9_-]{64,})\b`) @@ -61,42 +63,17 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result } if verify { - /* - curl --request POST \ - --url 'https://YOUR_DOMAIN/oauth/token' \ - --header 'content-type: application/x-www-form-urlencoded' \ - --data 'grant_type=authorization_code&client_id=W44JmL3qD6LxHeEJyKe9lMuhcwvPOaOq&client_secret=YOUR_CLIENT_SECRET&code=AUTHORIZATION_CODE&redirect_uri=undefined' - */ - - data := url.Values{} - data.Set("grant_type", "authorization_code") - data.Set("client_id", clientIdRes) - data.Set("client_secret", clientSecretRes) - data.Set("code", "AUTHORIZATION_CODE") - data.Set("redirect_uri", "undefined") - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+domainRes+"/oauth/token", strings.NewReader(data.Encode())) // URL-encoded payload - if err != nil { - continue + + client := s.client + if client == nil { + client = defaultClient } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - res, err := client.Do(req) - if err == nil { - defer res.Body.Close() - bodyBytes, err := io.ReadAll(res.Body) - if err != nil { - continue - } - body := string(bodyBytes) - - // if client_id and client_secret is valid -> 403 {"error":"invalid_grant","error_description":"Invalid authorization code"} - // if invalid -> 401 {"error":"access_denied","error_description":"Unauthorized"} - // ingenious! - - if !strings.Contains(body, "access_denied") { - s1.Verified = true - } + + isVerified, err := verifyTuple(ctx, client, domainRes, clientIdRes, clientSecretRes) + if err != nil { + s1.SetVerificationError(err, clientIdRes) } + s1.Verified = isVerified } results = append(results, s1) @@ -107,6 +84,61 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result return results, nil } +func verifyTuple(ctx context.Context, client *http.Client, domainRes, clientId, clientSecret string) (bool, error) { + /* + curl --request POST \ + --url 'https://YOUR_DOMAIN/oauth/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data 'grant_type=authorization_code&client_id=W44JmL3qD6LxHeEJyKe9lMuhcwvPOaOq&client_secret=YOUR_CLIENT_SECRET&code=AUTHORIZATION_CODE&redirect_uri=undefined' + */ + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", clientId) + data.Set("client_secret", clientSecret) + data.Set("code", "AUTHORIZATION_CODE") + data.Set("redirect_uri", "undefined") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+domainRes+"/oauth/token", strings.NewReader(data.Encode())) // URL-encoded payload + if err != nil { + return false, err + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + return false, err + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + switch resp.StatusCode { + case http.StatusOK: + // This condition will never meet due to invalid request body + return true, nil + case http.StatusUnauthorized: + return false, nil + case http.StatusForbidden: + // cross check about 'invalid_grant' or 'unauthorized_client' in response body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return false, err + } + bodyStr := string(bodyBytes) + if strings.Contains(bodyStr, "invalid_grant") || strings.Contains(bodyStr, "unauthorized_client") { + return true, nil + } + return false, nil + case http.StatusNotFound: + // domain does not exists - 404 not found + return false, nil + default: + return false, fmt.Errorf("unexpected HTTP response status %d", resp.StatusCode) + } +} + func (s Scanner) Type() detectorspb.DetectorType { return detectorspb.DetectorType_Auth0oauth } diff --git a/pkg/detectors/auth0oauth/auth0oauth_integeration_test.go b/pkg/detectors/auth0oauth/auth0oauth_integeration_test.go index 2363df8aa5e1..2a678030136e 100644 --- a/pkg/detectors/auth0oauth/auth0oauth_integeration_test.go +++ b/pkg/detectors/auth0oauth/auth0oauth_integeration_test.go @@ -10,23 +10,29 @@ import ( "time" "github.com/kylelemons/godebug/pretty" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" - "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" ) func TestAuth0oauth_FromChunk(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() - testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3") + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") if err != nil { t.Fatalf("could not get test secrets from GCP: %s", err) } domain := testSecrets.MustGetField("AUTH0_DOMAIN") - clientId := testSecrets.MustGetField("AUTH0_CLIENT_ID") // there is AUTH0_CLIENT_ID2 and AUTH0_CLIENT_SECRET2 pair as well + clientId := testSecrets.MustGetField("AUTH0_CLIENT_ID") clientSecret := testSecrets.MustGetField("AUTH0_CLIENT_SECRET") + + domainUnauthorized := testSecrets.MustGetField("AUTH0_DOMAIN_UNAUTHORIZED") + clientIdUnauthorized := testSecrets.MustGetField("AUTH0_CLIENT_ID_UNAUTHORIZED") + clientSecretUnauthorized := testSecrets.MustGetField("AUTH0_CLIENT_SECRET_UNAUTHORIZED") + + notFoundDomain := testSecrets.MustGetField("AUTH0_DOMAIN_NOT_FOUND") inactiveClientSecret := testSecrets.MustGetField("AUTH0_CLIENT_SECRET_INACTIVE") type args struct { @@ -58,6 +64,23 @@ func TestAuth0oauth_FromChunk(t *testing.T) { }, wantErr: false, }, + { + name: "found, verified but unauthorized", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a auth0 client id %s client secret %s domain %s", clientIdUnauthorized, clientSecretUnauthorized, domainUnauthorized)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_Auth0oauth, + Redacted: clientIdUnauthorized, + Verified: true, + }, + }, + wantErr: false, + }, { name: "found, unverified", s: Scanner{}, @@ -86,6 +109,23 @@ func TestAuth0oauth_FromChunk(t *testing.T) { want: nil, wantErr: false, }, + { + name: "domain does not exists", + s: Scanner{}, + args: args{ + ctx: context.Background(), + data: []byte(fmt.Sprintf("You can find a auth0 client id %s client secret %s domain %s", clientId, clientSecret, notFoundDomain)), + verify: true, + }, + want: []detectors.Result{ + { + DetectorType: detectorspb.DetectorType_Auth0oauth, + Redacted: clientId, + Verified: false, + }, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/engine/defaults/defaults.go b/pkg/engine/defaults/defaults.go index d7bc25e2a249..47dc3e43fe44 100644 --- a/pkg/engine/defaults/defaults.go +++ b/pkg/engine/defaults/defaults.go @@ -54,6 +54,7 @@ import ( atlassianv2 "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/atlassian/v2" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/audd" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/auth0managementapitoken" + "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/auth0oauth" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/autodesk" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/autoklose" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors/autopilot" @@ -882,7 +883,7 @@ func buildDetectorList() []detectors.Detector { &atlassianv2.Scanner{}, &audd.Scanner{}, &auth0managementapitoken.Scanner{}, - // &auth0oauth.Scanner{}, + &auth0oauth.Scanner{}, &autodesk.Scanner{}, &autoklose.Scanner{}, &autopilot.Scanner{}, From 0761334eec1b0176651f2f69b66dca1d227671f4 Mon Sep 17 00:00:00 2001 From: Miccah Date: Tue, 11 Feb 2025 09:53:48 -0800 Subject: [PATCH 3/4] Fix double summary printing introduced in 03ca8aaa08 (#3903) --- main.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/main.go b/main.go index c61788e02bda..fff23e77e36f 100644 --- a/main.go +++ b/main.go @@ -554,22 +554,6 @@ func run(state overseer.State) { logger.V(2).Info("exiting with code 183 because results were found") os.Exit(183) } - - // Print results. - logger.Info("finished scanning", - "chunks", metrics.ChunksScanned, - "bytes", metrics.BytesScanned, - "verified_secrets", metrics.VerifiedSecretsFound, - "unverified_secrets", metrics.UnverifiedSecretsFound, - "scan_duration", metrics.ScanDuration.String(), - "trufflehog_version", version.BuildVersion, - ) - - if metrics.hasFoundResults && *fail { - logger.V(2).Info("exiting with code 183 because results were found") - os.Exit(183) - } - } func compareScans(ctx context.Context, cmd string, cfg engine.Config) error { From f2dc96e8548d5fab0f6ffb3d47b9918b49fdde0a Mon Sep 17 00:00:00 2001 From: Abdul Basit Date: Tue, 11 Feb 2025 23:35:31 +0500 Subject: [PATCH 4/4] [Feat] implementation Notion analyzer (#3869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description: Since `Notion.co` allows to enable [capabilities](https://developers.notion.com/reference/capabilities) against integration token, It is a good candidate to build an analyzer for it. ![Screenshot 2025-02-04 at 6 46 37 PM](https://github.com/user-attachments/assets/2fb711fe-466f-4b78-9b25-56852f08cd3b) This PR implements notion analyzer along with its integration test. ### Checklist: * [ ] Tests passing (`make test-community`)? * [x] Lint passing (`make lint` this requires [golangci-lint](https://golangci-lint.run/welcome/install/#local-installation))? --- pkg/analyzer/analyzers/analyzers.go | 2 + .../analyzers/notion/expected_output.json | 1 + pkg/analyzer/analyzers/notion/notion.go | 389 ++++++++++++++++++ pkg/analyzer/analyzers/notion/notion_test.go | 100 +++++ pkg/analyzer/analyzers/notion/permissions.go | 91 ++++ .../analyzers/notion/permissions.yaml | 8 + pkg/analyzer/analyzers/notion/scopes.json | 47 +++ pkg/analyzer/cli.go | 4 + pkg/detectors/notion/notion.go | 1 + 9 files changed, 643 insertions(+) create mode 100644 pkg/analyzer/analyzers/notion/expected_output.json create mode 100644 pkg/analyzer/analyzers/notion/notion.go create mode 100644 pkg/analyzer/analyzers/notion/notion_test.go create mode 100644 pkg/analyzer/analyzers/notion/permissions.go create mode 100644 pkg/analyzer/analyzers/notion/permissions.yaml create mode 100644 pkg/analyzer/analyzers/notion/scopes.json diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 168f9bd83a9b..565118a2f386 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -82,6 +82,7 @@ const ( AnalyzerTypeStripe AnalyzerTypeTwilio AnalyzerTypePrivateKey + AnalyzerTypeNotion // Add new items here with AnalyzerType prefix ) @@ -110,6 +111,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{ AnalyzerTypeStripe: "Stripe", AnalyzerTypeTwilio: "Twilio", AnalyzerTypePrivateKey: "PrivateKey", + AnalyzerTypeNotion: "Notion", // Add new mappings here } diff --git a/pkg/analyzer/analyzers/notion/expected_output.json b/pkg/analyzer/analyzers/notion/expected_output.json new file mode 100644 index 000000000000..203535ec53cb --- /dev/null +++ b/pkg/analyzer/analyzers/notion/expected_output.json @@ -0,0 +1 @@ +{"AnalyzerType":22,"Bindings":[{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"insert_content","Parent":null}},{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"read_content","Parent":null}},{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"read_users_with_email","Parent":null}},{"Resource":{"Name":"hooman","FullyQualifiedName":"notion.so/bot/62faeec3-a948-4dd4-90ae-426e0b192902","Type":"bot","Metadata":{"workspace":"hoomanit"},"Parent":null},"Permission":{"Value":"update_content","Parent":null}}],"UnboundedResources":[{"Name":"hooman","FullyQualifiedName":"notion.so/person/3d0600fa-fa18-427d-8abc-58b662f0d209","Type":"person","Metadata":{"email":"rendyplayground@gmail.com"},"Parent":null}],"Metadata":null} \ No newline at end of file diff --git a/pkg/analyzer/analyzers/notion/notion.go b/pkg/analyzer/analyzers/notion/notion.go new file mode 100644 index 000000000000..7533114e849f --- /dev/null +++ b/pkg/analyzer/analyzers/notion/notion.go @@ -0,0 +1,389 @@ +//go:generate generate_permissions permissions.yaml permissions.go notion + +package notion + +import ( + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + + "github.com/fatih/color" + "github.com/jedib0t/go-pretty/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +var _ analyzers.Analyzer = (*Analyzer)(nil) + +type Analyzer struct { + Cfg *config.Config +} + +func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeNotion } + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + key, ok := credInfo["key"] + if !ok { + return nil, errors.New("missing key in credInfo") + } + info, err := AnalyzePermissions(a.Cfg, key) + if err != nil { + return nil, err + } + return secretInfoToAnalyzerResult(info), nil +} + +func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { + if info == nil { + return nil + } + result := analyzers.AnalyzerResult{ + AnalyzerType: analyzers.AnalyzerTypeNotion, + Metadata: nil, + Bindings: make([]analyzers.Binding, len(info.Permissions)), + UnboundedResources: make([]analyzers.Resource, 0, len(info.WorkspaceUsers)), + } + + resource := analyzers.Resource{ + Name: info.Bot.Name, + FullyQualifiedName: "notion.so/bot/" + info.Bot.Id, + Type: info.Bot.Type, + Metadata: map[string]interface{}{ + "workspace": info.Bot.GetWorkspaceName(), + }, + } + + for idx, permission := range info.Permissions { + result.Bindings[idx] = analyzers.Binding{ + Resource: resource, + Permission: analyzers.Permission{ + Value: permission, + }, + } + } + + // We can find list of users in the current workspace + // if the API key has read_user permission, so these can be + // unbounded resources + for _, user := range info.WorkspaceUsers { + if info.Bot.Id == user.Id { + // Skip the bot itself + continue + } + unboundresource := analyzers.Resource{ + Name: user.Name, + FullyQualifiedName: fmt.Sprintf("notion.so/%s/%s", user.Type, user.Id), + Type: user.Type, // person or bot + } + if user.Person.Email != "" { + unboundresource.Metadata = map[string]interface{}{ + "email": user.Person.Email, + } + } + + result.UnboundedResources = append(result.UnboundedResources, unboundresource) + } + + return &result +} + +//go:embed scopes.json +var scopesConfig []byte + +type HttpStatusTest struct { + Endpoint string `json:"endpoint"` + Method string `json:"method"` + Payload interface{} `json:"payload"` + ValidStatuses []int `json:"valid_status_code"` + InvalidStatuses []int `json:"invalid_status_code"` +} + +func StatusContains(status int, vals []int) bool { + for _, v := range vals { + if status == v { + return true + } + } + return false +} + +func (h *HttpStatusTest) RunTest(cfg *config.Config, headers map[string]string) (bool, error) { + // If body data, marshal to JSON + var data io.Reader + if h.Payload != nil { + jsonData, err := json.Marshal(h.Payload) + if err != nil { + return false, err + } + data = bytes.NewBuffer(jsonData) + } + + // Create new HTTP request + client := analyzers.NewAnalyzeClientUnrestricted(cfg) + req, err := http.NewRequest(h.Method, h.Endpoint, data) + if err != nil { + return false, err + } + + // Add custom headers if provided + for key, value := range headers { + req.Header.Set(key, value) + } + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + // Check response status code + switch { + case StatusContains(resp.StatusCode, h.ValidStatuses): + return true, nil + case StatusContains(resp.StatusCode, h.InvalidStatuses): + return false, nil + default: + return false, errors.New("error checking response status code") + } +} + +type Scope struct { + Name string `json:"name"` + HttpTest HttpStatusTest `json:"test"` +} + +func readInScopes() ([]Scope, error) { + var scopes []Scope + if err := json.Unmarshal(scopesConfig, &scopes); err != nil { + return nil, err + } + + return scopes, nil +} + +func getPermissions(cfg *config.Config, key string) ([]string, error) { + scopes, err := readInScopes() + if err != nil { + return nil, fmt.Errorf("reading in scopes: %w", err) + } + + permissions := make([]string, 0, len(scopes)) + for _, scope := range scopes { + status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": "Bearer " + key, "Notion-Version": "2022-06-28"}) + if err != nil { + return nil, fmt.Errorf("running test: %w", err) + } + if status { + permissions = append(permissions, scope.Name) + } + } + + return permissions, nil +} + +type SecretInfo struct { + Bot *bot + WorkspaceUsers []user + Permissions []string +} + +type user struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Person struct { + Email string `json:"email"` + } +} + +type bot struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Bot struct { + Owner *struct { + Type string `json:"type"` + } + WorkspaceName string `json:"workspace_name"` + } `json:"bot"` +} + +func (b *bot) GetWorkspaceName() string { + return b.Bot.WorkspaceName +} + +func (b *bot) OwnedBy() string { + if b.Bot.Owner != nil { + return b.Bot.Owner.Type + } + return "N/A" +} + +func AnalyzeAndPrintPermissions(cfg *config.Config, key string) { + info, err := AnalyzePermissions(cfg, key) + if err != nil { + color.Red("[x] Error : %s", err.Error()) + return + } + + color.Green("[!] Valid Notion API key\n\n") + + color.Green("[i] Bot: %s (%s)\n", info.Bot.Name, info.Bot.Id) + color.Green("[i] Bot Owned By: %s\n", info.Bot.OwnedBy()) + + if info.Bot.GetWorkspaceName() != "" { + color.Green("[i] Workspace: %s\n\n", info.Bot.GetWorkspaceName()) + } + + printPermissions(info.Permissions) + if len(info.WorkspaceUsers) > 0 { + printUsers(info.WorkspaceUsers) + } + color.Yellow("\n[i] Expires: Never") + +} + +func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { + permissions := make([]string, 0) + + client := analyzers.NewAnalyzeClient(cfg) + + bot, err := getBotInfo(client, key) + if err != nil { + return nil, err + } + + credPermissions, err := getPermissions(cfg, key) + if err != nil { + return nil, err + } + + permissions = append(permissions, credPermissions...) + + users, err := getWorkspaceUsers(client, key) + if err != nil { + return nil, fmt.Errorf("error getting user permission: %s", err.Error()) + } + + // check if email is returned in users to determine permission + for _, user := range users { + if user.Type == "person" { + if user.Person.Email == "" { + permissions = append(permissions, PermissionStrings[ReadUsersWithoutEmail]) + } else { + permissions = append(permissions, PermissionStrings[ReadUsersWithEmail]) + } + break + } + } + return &SecretInfo{ + Bot: bot, + Permissions: permissions, + WorkspaceUsers: users, + }, nil +} + +func printPermissions(permissions []string) { + color.Yellow("[i] Permissions:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Permission"}) + for _, permission := range permissions { + t.AppendRow(table.Row{color.GreenString(permission)}) + } + t.Render() +} + +func printUsers(users []user) { + color.Yellow("\n[i] Workspace Users:") + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"ID", "Name", "Type", "Email"}) + for _, user := range users { + t.AppendRow(table.Row{color.GreenString(user.Id), color.GreenString(user.Name), color.GreenString(user.Type), color.GreenString(user.Person.Email)}) + } + t.Render() +} + +func getBotInfo(client *http.Client, key string) (*bot, error) { + // Create new HTTP request + req, err := http.NewRequest(http.MethodGet, "https://api.notion.com/v1/users/me", http.NoBody) + if err != nil { + return nil, err + } + + // Add custom headers if provided + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Notion-Version", "2022-06-28") + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + me := &bot{} + err = json.NewDecoder(resp.Body).Decode(me) + if err != nil { + return nil, err + } + return me, nil + case http.StatusUnauthorized: + return nil, errors.New("invalid API key") + default: + return nil, errors.New("error getting bot info") + } +} + +// Decode response body +type usersResponse struct { + Results []user `json:"results"` +} + +func getWorkspaceUsers(client *http.Client, key string) ([]user, error) { + // Create new HTTP request + req, err := http.NewRequest(http.MethodGet, "https://api.notion.com/v1/users", http.NoBody) + if err != nil { + return nil, err + } + + // Add custom headers if provided + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Notion-Version", "2022-06-28") + + // Execute HTTP Request + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + response := &usersResponse{} + err = json.NewDecoder(resp.Body).Decode(response) + if err != nil { + return nil, err + } + return response.Results, nil + case http.StatusUnauthorized: + return nil, errors.New("invalid API key") + case http.StatusForbidden: + return nil, nil // no permission + case http.StatusNotFound: + return nil, errors.New("workspace not found") + default: + return nil, errors.New("error checking user permissions") + } + +} diff --git a/pkg/analyzer/analyzers/notion/notion_test.go b/pkg/analyzer/analyzers/notion/notion_test.go new file mode 100644 index 000000000000..f6d92eaabae1 --- /dev/null +++ b/pkg/analyzer/analyzers/notion/notion_test.go @@ -0,0 +1,100 @@ +package notion + +import ( + _ "embed" + "encoding/json" + "sort" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +//go:embed expected_output.json +var expectedOutput []byte + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors3") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "valid notion key", + key: testSecrets.MustGetField("NOTION_TOKEN"), + want: string(expectedOutput), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{Cfg: &config.Config{}} + got, err := a.Analyze(ctx, map[string]string{"key": tt.key}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // bindings need to be in the same order to be comparable + sortBindings(got.Bindings) + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // bindings need to be in the same order to be comparable + sortBindings(wantObj.Bindings) + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented, wantIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + wantIndented, err = json.MarshalIndent(wantObj, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} + +// Helper function to sort bindings +func sortBindings(bindings []analyzers.Binding) { + sort.SliceStable(bindings, func(i, j int) bool { + if bindings[i].Resource.Name == bindings[j].Resource.Name { + return bindings[i].Permission.Value < bindings[j].Permission.Value + } + return bindings[i].Resource.Name < bindings[j].Resource.Name + }) +} diff --git a/pkg/analyzer/analyzers/notion/permissions.go b/pkg/analyzer/analyzers/notion/permissions.go new file mode 100644 index 000000000000..deb0259665cc --- /dev/null +++ b/pkg/analyzer/analyzers/notion/permissions.go @@ -0,0 +1,91 @@ +// Code generated by go generate; DO NOT EDIT. +package notion + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + ReadContent Permission = iota + UpdateContent Permission = iota + InsertContent Permission = iota + ReadComments Permission = iota + InsertComments Permission = iota + ReadUsersWithEmail Permission = iota + ReadUsersWithoutEmail Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + ReadContent: "read_content", + UpdateContent: "update_content", + InsertContent: "insert_content", + ReadComments: "read_comments", + InsertComments: "insert_comments", + ReadUsersWithEmail: "read_users_with_email", + ReadUsersWithoutEmail: "read_users_without_email", + } + + StringToPermission = map[string]Permission{ + "read_content": ReadContent, + "update_content": UpdateContent, + "insert_content": InsertContent, + "read_comments": ReadComments, + "insert_comments": InsertComments, + "read_users_with_email": ReadUsersWithEmail, + "read_users_without_email": ReadUsersWithoutEmail, + } + + PermissionIDs = map[Permission]int{ + ReadContent: 1, + UpdateContent: 2, + InsertContent: 3, + ReadComments: 4, + InsertComments: 5, + ReadUsersWithEmail: 6, + ReadUsersWithoutEmail: 7, + } + + IdToPermission = map[int]Permission{ + 1: ReadContent, + 2: UpdateContent, + 3: InsertContent, + 4: ReadComments, + 5: InsertComments, + 6: ReadUsersWithEmail, + 7: ReadUsersWithoutEmail, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := PermissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := PermissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := StringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := IdToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/notion/permissions.yaml b/pkg/analyzer/analyzers/notion/permissions.yaml new file mode 100644 index 000000000000..269a1b0f6fe3 --- /dev/null +++ b/pkg/analyzer/analyzers/notion/permissions.yaml @@ -0,0 +1,8 @@ +permissions: + - read_content + - update_content + - insert_content + - read_comments + - insert_comments + - read_users_with_email + - read_users_without_email \ No newline at end of file diff --git a/pkg/analyzer/analyzers/notion/scopes.json b/pkg/analyzer/analyzers/notion/scopes.json new file mode 100644 index 000000000000..25f645dc5808 --- /dev/null +++ b/pkg/analyzer/analyzers/notion/scopes.json @@ -0,0 +1,47 @@ +[ + { + "name": "read_content", + "test": { + "endpoint": "https://api.notion.com/v1/pages/`nowaythiscanexist", + "method": "GET", + "valid_status_code": [400], + "invalid_status_code": [403] + } + }, + { + "name": "update_content", + "test": { + "endpoint": "https://api.notion.com/v1/pages/`nowaythiscanexist", + "method": "PATCH", + "valid_status_code": [400], + "invalid_status_code": [403] + } + }, + { + "name": "insert_content", + "test": { + "endpoint": "https://api.notion.com/v1/pages", + "method": "POST", + "valid_status_code": [400], + "invalid_status_code": [403] + } + }, + { + "name": "read_comments", + "test": { + "endpoint": "https://api.notion.com/v1/comments", + "method": "GET", + "valid_status_code": [400], + "invalid_status_code": [403] + } + }, + { + "name": "insert_comments", + "test": { + "endpoint": "https://api.notion.com/v1/comments", + "method": "POST", + "valid_status_code": [400], + "invalid_status_code": [403] + } + } +] \ No newline at end of file diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index cc1e12170e3f..f7cbebcef1a3 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/alecthomas/kingpin/v2" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/airbrake" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/asana" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/bitbucket" @@ -14,6 +15,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mailchimp" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mailgun" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/mysql" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/notion" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/openai" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/opsgenie" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postgres" @@ -85,6 +87,8 @@ func Run(keyType string, secretInfo SecretInfo) { opsgenie.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) case "privatekey": privatekey.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "notion": + notion.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) case "dockerhub": dockerhub.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["username"], secretInfo.Parts["pat"]) } diff --git a/pkg/detectors/notion/notion.go b/pkg/detectors/notion/notion.go index 3d33a4121072..614981e8689b 100644 --- a/pkg/detectors/notion/notion.go +++ b/pkg/detectors/notion/notion.go @@ -60,6 +60,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result // Notion returns 401 for all non-valid keys, thus 403 indicates it has fine-tuned permissions, // /v1/search, /v1/databases/*, etc. may work. s1.Verified = true + s1.AnalysisInfo = map[string]string{"key": resMatch} } } else {