diff --git a/pkg/analyzer/analyzers/gitlab/expected_output.json b/pkg/analyzer/analyzers/gitlab/expected_output.json new file mode 100644 index 000000000000..dd20af46093d --- /dev/null +++ b/pkg/analyzer/analyzers/gitlab/expected_output.json @@ -0,0 +1 @@ +{"AnalyzerType":5,"Bindings":[{"Resource":{"Name":"gitlab.com/user/22466472","FullyQualifiedName":"gitlab.com/user/22466472","Type":"user","Metadata":{"token_created_at":"2024-08-15T06:33:00.337Z","token_expires_at":"2025-08-15","token_id":10470457,"token_name":"test-project-token","token_revoked":false},"Parent":null},"Permission":{"Value":"read_api","Parent":null}},{"Resource":{"Name":"gitlab.com/user/22466472","FullyQualifiedName":"gitlab.com/user/22466472","Type":"user","Metadata":{"token_created_at":"2024-08-15T06:33:00.337Z","token_expires_at":"2025-08-15","token_id":10470457,"token_name":"test-project-token","token_revoked":false},"Parent":null},"Permission":{"Value":"read_repository","Parent":null}},{"Resource":{"Name":"truffletester / trufflehog","FullyQualifiedName":"gitlab.com/project/60871295","Type":"project","Metadata":null,"Parent":null},"Permission":{"Value":"Developer","Parent":null}}],"UnboundedResources":null,"Metadata":{"enterprise":true,"version":"17.6.0-pre"}} \ No newline at end of file diff --git a/pkg/analyzer/analyzers/gitlab/gitlab.go b/pkg/analyzer/analyzers/gitlab/gitlab.go index 7975d232d64c..dbfd89119f19 100644 --- a/pkg/analyzer/analyzers/gitlab/gitlab.go +++ b/pkg/analyzer/analyzers/gitlab/gitlab.go @@ -1,8 +1,11 @@ +//go:generate generate_permissions permissions.yaml permissions.go gitlab + package gitlab import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -25,27 +28,93 @@ type Analyzer struct { func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeGitLab } func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { - _, err := AnalyzePermissions(a.Cfg, credInfo["key"]) + key, ok := credInfo["key"] + if !ok { + return nil, errors.New("key not found in credentialInfo") + } + + info, err := AnalyzePermissions(a.Cfg, key) if err != nil { return nil, err } - return nil, fmt.Errorf("not implemented") + return secretInfoToAnalyzerResult(info), nil +} + +func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { + result := analyzers.AnalyzerResult{ + AnalyzerType: analyzers.AnalyzerTypeGitLab, + Metadata: map[string]any{ + "version": info.Metadata.Version, + "enterprise": info.Metadata.Enterprise, + }, + Bindings: []analyzers.Binding{}, + } + + // Add user and it's permissions to bindings + userFullyQualifiedName := fmt.Sprintf("gitlab.com/user/%d", info.AccessToken.UserID) + userResource := analyzers.Resource{ + Name: userFullyQualifiedName, + FullyQualifiedName: userFullyQualifiedName, + Type: "user", + Metadata: map[string]any{ + "token_name": info.AccessToken.Name, + "token_id": info.AccessToken.ID, + "token_created_at": info.AccessToken.CreatedAt, + "token_revoked": info.AccessToken.Revoked, + "token_expires_at": info.AccessToken.ExpiresAt, + }, + } + + for _, scope := range info.AccessToken.Scopes { + result.Bindings = append(result.Bindings, analyzers.Binding{ + Resource: userResource, + Permission: analyzers.Permission{ + Value: scope, + }, + }) + } + + // append project and it's permissions to bindings + for _, project := range info.Projects { + projectResource := analyzers.Resource{ + Name: project.NameWithNamespace, + FullyQualifiedName: fmt.Sprintf("gitlab.com/project/%d", project.ID), + Type: "project", + } + + accessLevel, ok := access_level_map[project.Permissions.ProjectAccess.AccessLevel] + if !ok { + continue + } + + result.Bindings = append(result.Bindings, analyzers.Binding{ + Resource: projectResource, + Permission: analyzers.Permission{ + Value: accessLevel, + }, + }) + } + + return &result } // consider calling /api/v4/metadata to learn about gitlab instance version and whether neterrprises is enabled -// we'll call /api/v4/personal_access_tokens and /api/v4/user and then filter down to scopes. +// we'll call /api/v4/personal_access_tokens and then filter down to scopes. type AccessTokenJSON struct { + ID int `json:"id"` Name string `json:"name"` Revoked bool `json:"revoked"` CreatedAt string `json:"created_at"` Scopes []string `json:"scopes"` LastUsedAt string `json:"last_used_at"` ExpiresAt string `json:"expires_at"` + UserID int `json:"user_id"` } type ProjectsJSON struct { + ID int `json:"id"` NameWithNamespace string `json:"name_with_namespace"` Permissions struct { ProjectAccess struct { @@ -248,6 +317,7 @@ func printTokenInfo(token AccessTokenJSON) { color.Green("Token Name: %s\n", token.Name) color.Green("Created At: %s\n", token.CreatedAt) color.Green("Last Used At: %s\n", token.LastUsedAt) + color.Green("User ID: %d\n", token.UserID) color.Green("Expires At: %s (%v remaining)\n\n", token.ExpiresAt, getRemainingTime(token.ExpiresAt)) if token.Revoked { color.Red("Token Revoked: %v\n", token.Revoked) diff --git a/pkg/analyzer/analyzers/gitlab/gitlab_test.go b/pkg/analyzer/analyzers/gitlab/gitlab_test.go new file mode 100644 index 000000000000..bc62df9b36b1 --- /dev/null +++ b/pkg/analyzer/analyzers/gitlab/gitlab_test.go @@ -0,0 +1,78 @@ +package gitlab + +import ( + _ "embed" + "encoding/json" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "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*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) + } + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "valid gitlab access token", + key: testSecrets.MustGetField("GITLABV2"), + want: string(expectedOutput), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{} + 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 + } + + // 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 []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = \n%s", gotIndented) + } + }) + } +} diff --git a/pkg/analyzer/analyzers/gitlab/permissions.go b/pkg/analyzer/analyzers/gitlab/permissions.go new file mode 100644 index 000000000000..359ea85bc824 --- /dev/null +++ b/pkg/analyzer/analyzers/gitlab/permissions.go @@ -0,0 +1,126 @@ +// Code generated by go generate; DO NOT EDIT. +package gitlab + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + Api Permission = iota + ReadUser Permission = iota + ReadApi Permission = iota + ReadRepository Permission = iota + WriteRepository Permission = iota + ReadRegistry Permission = iota + WriteRegistry Permission = iota + Sudo Permission = iota + AdminMode Permission = iota + CreateRunner Permission = iota + ManageRunner Permission = iota + AiFeatures Permission = iota + K8sProxy Permission = iota + ReadServicePing Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + Api: "api", + ReadUser: "read_user", + ReadApi: "read_api", + ReadRepository: "read_repository", + WriteRepository: "write_repository", + ReadRegistry: "read_registry", + WriteRegistry: "write_registry", + Sudo: "sudo", + AdminMode: "admin_mode", + CreateRunner: "create_runner", + ManageRunner: "manage_runner", + AiFeatures: "ai_features", + K8sProxy: "k8s_proxy", + ReadServicePing: "read_service_ping", + } + + StringToPermission = map[string]Permission{ + "api": Api, + "read_user": ReadUser, + "read_api": ReadApi, + "read_repository": ReadRepository, + "write_repository": WriteRepository, + "read_registry": ReadRegistry, + "write_registry": WriteRegistry, + "sudo": Sudo, + "admin_mode": AdminMode, + "create_runner": CreateRunner, + "manage_runner": ManageRunner, + "ai_features": AiFeatures, + "k8s_proxy": K8sProxy, + "read_service_ping": ReadServicePing, + } + + PermissionIDs = map[Permission]int{ + Api: 1, + ReadUser: 2, + ReadApi: 3, + ReadRepository: 4, + WriteRepository: 5, + ReadRegistry: 6, + WriteRegistry: 7, + Sudo: 8, + AdminMode: 9, + CreateRunner: 10, + ManageRunner: 11, + AiFeatures: 12, + K8sProxy: 13, + ReadServicePing: 14, + } + + IdToPermission = map[int]Permission{ + 1: Api, + 2: ReadUser, + 3: ReadApi, + 4: ReadRepository, + 5: WriteRepository, + 6: ReadRegistry, + 7: WriteRegistry, + 8: Sudo, + 9: AdminMode, + 10: CreateRunner, + 11: ManageRunner, + 12: AiFeatures, + 13: K8sProxy, + 14: ReadServicePing, + } +) + +// 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/gitlab/permissions.yaml b/pkg/analyzer/analyzers/gitlab/permissions.yaml new file mode 100644 index 000000000000..2dfb858ebd3d --- /dev/null +++ b/pkg/analyzer/analyzers/gitlab/permissions.yaml @@ -0,0 +1,15 @@ +permissions: + - api + - read_user + - read_api + - read_repository + - write_repository + - read_registry + - write_registry + - sudo + - admin_mode + - create_runner + - manage_runner + - ai_features + - k8s_proxy + - read_service_ping diff --git a/pkg/detectors/gitlab/v1/gitlab.go b/pkg/detectors/gitlab/v1/gitlab.go index 62a5db140e96..5e9c0ea94b54 100644 --- a/pkg/detectors/gitlab/v1/gitlab.go +++ b/pkg/detectors/gitlab/v1/gitlab.go @@ -75,6 +75,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.ExtraData = extraData s1.SetVerificationError(verificationErr, resMatch) + s1.AnalysisInfo = map[string]string{ + "key": resMatch, + } } results = append(results, s1) diff --git a/pkg/detectors/gitlab/v1/gitlab_v1_test.go b/pkg/detectors/gitlab/v1/gitlab_v1_test.go index e521f45b15fb..f053f4fd3482 100644 --- a/pkg/detectors/gitlab/v1/gitlab_v1_test.go +++ b/pkg/detectors/gitlab/v1/gitlab_v1_test.go @@ -187,6 +187,7 @@ func TestGitlab_FromChunk(t *testing.T) { if (got[i].VerificationError() != nil) != tt.wantVerificationErr { t.Fatalf(" wantVerificationError = %v, verification error = %v,", tt.wantVerificationErr, got[i].VerificationError()) } + got[i].AnalysisInfo = nil } opts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") if diff := cmp.Diff(got, tt.want, opts); diff != "" { diff --git a/pkg/detectors/gitlab/v1/gitlab_v2_test.go b/pkg/detectors/gitlab/v1/gitlab_v2_test.go index 13063e1a5d59..3f2c5cba2a24 100644 --- a/pkg/detectors/gitlab/v1/gitlab_v2_test.go +++ b/pkg/detectors/gitlab/v1/gitlab_v2_test.go @@ -87,6 +87,7 @@ func TestGitlab_FromChunk_WithV2Secrets(t *testing.T) { if (got[i].VerificationError() != nil) != tt.wantVerificationErr { t.Fatalf(" wantVerificationError = %v, verification error = %v,", tt.wantVerificationErr, got[i].VerificationError()) } + got[i].AnalysisInfo = nil } opts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") if diff := cmp.Diff(got, tt.want, opts); diff != "" { diff --git a/pkg/detectors/gitlab/v2/gitlab_v1_test.go b/pkg/detectors/gitlab/v2/gitlab_v1_test.go index 19e2c0b5072e..ad5dbaf020f6 100644 --- a/pkg/detectors/gitlab/v2/gitlab_v1_test.go +++ b/pkg/detectors/gitlab/v2/gitlab_v1_test.go @@ -98,6 +98,7 @@ func TestGitlabV2_FromChunk_WithV1Secrets(t *testing.T) { if (got[i].VerificationError() != nil) != tt.wantVerificationErr { t.Fatalf(" wantVerificationError = %v, verification error = %v,", tt.wantVerificationErr, got[i].VerificationError()) } + got[i].AnalysisInfo = nil } opts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") if diff := cmp.Diff(got, tt.want, opts); diff != "" { diff --git a/pkg/detectors/gitlab/v2/gitlab_v2.go b/pkg/detectors/gitlab/v2/gitlab_v2.go index ee04aee6f2f4..98233fe4ac0b 100644 --- a/pkg/detectors/gitlab/v2/gitlab_v2.go +++ b/pkg/detectors/gitlab/v2/gitlab_v2.go @@ -64,6 +64,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result s1.ExtraData = extraData s1.SetVerificationError(verificationErr, resMatch) + s1.AnalysisInfo = map[string]string{ + "key": resMatch, + } } results = append(results, s1) diff --git a/pkg/detectors/gitlab/v2/gitlab_v2_test.go b/pkg/detectors/gitlab/v2/gitlab_v2_test.go index 72706b1b4c01..43c6a0dedf4e 100644 --- a/pkg/detectors/gitlab/v2/gitlab_v2_test.go +++ b/pkg/detectors/gitlab/v2/gitlab_v2_test.go @@ -167,6 +167,7 @@ func TestGitlabV2_FromChunk(t *testing.T) { if (got[i].VerificationError() != nil) != tt.wantVerificationErr { t.Fatalf(" wantVerificationError = %v, verification error = %v,", tt.wantVerificationErr, got[i].VerificationError()) } + got[i].AnalysisInfo = nil } opts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") if diff := cmp.Diff(got, tt.want, opts); diff != "" {