From 08e669e871dbb7b5b5a9fff44e615c2d2975a347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 08:50:36 +0000 Subject: [PATCH 1/4] Initial plan From 5295278b15b268c4715db3c6b1ead95d6fd2c6fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 09:05:21 +0000 Subject: [PATCH 2/4] Add GitLab users enum commands Agent-Logs-Url: https://github.com/CompassSecurity/pipeleek/sessions/1c0daa5a-30ad-4c4c-a4df-03b95c0ab3b0 Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- internal/cmd/gitlab/gitlab.go | 2 + internal/cmd/gitlab/gitlab_test.go | 22 ++++++ internal/cmd/gitlab/gitlab_unauth.go | 2 + internal/cmd/gitlab/users/enum.go | 49 ++++++++++++++ internal/cmd/gitlab/users/users.go | 15 +++++ pkg/gitlab/users/enum.go | 69 +++++++++++++++++++ tests/e2e/gitlab/unauth/users/enum_test.go | 53 +++++++++++++++ tests/e2e/gitlab/users/enum_test.go | 78 ++++++++++++++++++++++ 8 files changed, 290 insertions(+) create mode 100644 internal/cmd/gitlab/users/enum.go create mode 100644 internal/cmd/gitlab/users/users.go create mode 100644 pkg/gitlab/users/enum.go create mode 100644 tests/e2e/gitlab/unauth/users/enum_test.go create mode 100644 tests/e2e/gitlab/users/enum_test.go diff --git a/internal/cmd/gitlab/gitlab.go b/internal/cmd/gitlab/gitlab.go index c2625766..06a07151 100644 --- a/internal/cmd/gitlab/gitlab.go +++ b/internal/cmd/gitlab/gitlab.go @@ -12,6 +12,7 @@ import ( securefiles "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/secureFiles" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/snippets" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/tf" + "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/users" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/variables" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/vuln" "github.com/spf13/cobra" @@ -55,6 +56,7 @@ For SOCKS5 proxy: glCmd.AddCommand(schedule.NewScheduleCmd()) glCmd.AddCommand(snippets.NewSnippetsRootCmd()) glCmd.AddCommand(tf.NewTFCmd()) + glCmd.AddCommand(users.NewUsersRootCmd()) glCmd.PersistentFlags().StringVarP(&gitlabUrl, "gitlab", "g", "", "GitLab instance URL") glCmd.PersistentFlags().StringVarP(&gitlabApiToken, "token", "t", "", "GitLab API Token") diff --git a/internal/cmd/gitlab/gitlab_test.go b/internal/cmd/gitlab/gitlab_test.go index d4998967..a17be5dc 100644 --- a/internal/cmd/gitlab/gitlab_test.go +++ b/internal/cmd/gitlab/gitlab_test.go @@ -8,6 +8,7 @@ import ( "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/scanpublic" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/shodan" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/snippets" + "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/users" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/variables" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/vuln" "github.com/stretchr/testify/assert" @@ -39,6 +40,11 @@ func TestNewGitLabRootCmd(t *testing.T) { require.NoError(t, err) require.NotNil(t, snippetsCmd) assert.Equal(t, "snippets", snippetsCmd.Name()) + + usersCmd, _, err := cmd.Find([]string{"users"}) + require.NoError(t, err) + require.NotNil(t, usersCmd) + assert.Equal(t, "users", usersCmd.Name()) } func TestNewVulnCmd(t *testing.T) { @@ -82,6 +88,18 @@ func TestNewRegisterCmd(t *testing.T) { assert.NotNil(t, flags.Lookup("gitlab"), "'gitlab' flag should be registered") } +func TestNewUsersRootCmd(t *testing.T) { + cmd := users.NewUsersRootCmd() + + require.NotNil(t, cmd) + assert.Equal(t, "users", cmd.Use) + assert.NotEmpty(t, cmd.Short) + + enumCmd, _, err := cmd.Find([]string{"enum"}) + require.NoError(t, err) + assert.NotNil(t, enumCmd) +} + func TestNewShodanCmd(t *testing.T) { cmd := shodan.NewShodanCmd() @@ -126,6 +144,10 @@ func TestNewGitLabRootUnauthenticatedCmd(t *testing.T) { publicScanCmd, _, err := cmd.Find([]string{"scan"}) require.NoError(t, err) assert.NotNil(t, publicScanCmd) + + usersCmd, _, err := cmd.Find([]string{"users"}) + require.NoError(t, err) + assert.NotNil(t, usersCmd) } func TestNewScanPublicCmd(t *testing.T) { diff --git a/internal/cmd/gitlab/gitlab_unauth.go b/internal/cmd/gitlab/gitlab_unauth.go index 4aae1d37..bc0618ab 100644 --- a/internal/cmd/gitlab/gitlab_unauth.go +++ b/internal/cmd/gitlab/gitlab_unauth.go @@ -4,6 +4,7 @@ import ( "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/register" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/scanpublic" "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/shodan" + "github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/users" "github.com/spf13/cobra" ) @@ -18,6 +19,7 @@ func NewGitLabRootUnauthenticatedCmd() *cobra.Command { glunaCmd.AddCommand(shodan.NewShodanCmd()) glunaCmd.AddCommand(register.NewRegisterCmd()) glunaCmd.AddCommand(scanpublic.NewScanPublicCmd()) + glunaCmd.AddCommand(users.NewUsersRootCmd()) return glunaCmd } diff --git a/internal/cmd/gitlab/users/enum.go b/internal/cmd/gitlab/users/enum.go new file mode 100644 index 00000000..03a0dc2f --- /dev/null +++ b/internal/cmd/gitlab/users/enum.go @@ -0,0 +1,49 @@ +package users + +import ( + "github.com/CompassSecurity/pipeleek/pkg/config" + pkgusers "github.com/CompassSecurity/pipeleek/pkg/gitlab/users" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func NewEnumCmd() *cobra.Command { + enumCmd := &cobra.Command{ + Use: "enum", + Short: "Enumerate GitLab users", + Long: "Enumerate GitLab users visible via the GitLab users API.", + Example: `pipeleek gl users enum --gitlab https://gitlab.example.com --token glpat-xxxxxxxxxxx`, + Run: Enum, + } + enumCmd.Flags().StringP("gitlab", "g", "", "GitLab instance URL") + enumCmd.Flags().StringP("token", "t", "", "GitLab API Token") + + return enumCmd +} + +func Enum(cmd *cobra.Command, args []string) { + if err := config.AutoBindFlags(cmd, map[string]string{ + "gitlab": "gitlab.url", + "token": "gitlab.token", + }); err != nil { + log.Fatal().Err(err).Msg("Failed to bind command flags to configuration keys") + } + + if err := config.RequireConfigKeys("gitlab.url"); err != nil { + log.Fatal().Err(err).Msg("required configuration missing") + } + + gitlabURL := config.GetString("gitlab.url") + gitlabAPIToken := config.GetString("gitlab.token") + + if err := config.ValidateURL(gitlabURL, "GitLab URL"); err != nil { + log.Fatal().Err(err).Msg("Invalid GitLab URL") + } + if gitlabAPIToken != "" { + if err := config.ValidateToken(gitlabAPIToken, "GitLab API Token"); err != nil { + log.Fatal().Err(err).Msg("Invalid GitLab API Token") + } + } + + pkgusers.RunEnum(gitlabURL, gitlabAPIToken) +} diff --git a/internal/cmd/gitlab/users/users.go b/internal/cmd/gitlab/users/users.go new file mode 100644 index 00000000..031ddabd --- /dev/null +++ b/internal/cmd/gitlab/users/users.go @@ -0,0 +1,15 @@ +package users + +import "github.com/spf13/cobra" + +func NewUsersRootCmd() *cobra.Command { + usersCmd := &cobra.Command{ + Use: "users", + Short: "GitLab user related commands", + Long: "Commands to enumerate GitLab users.", + } + + usersCmd.AddCommand(NewEnumCmd()) + + return usersCmd +} diff --git a/pkg/gitlab/users/enum.go b/pkg/gitlab/users/enum.go new file mode 100644 index 00000000..6a5abe8c --- /dev/null +++ b/pkg/gitlab/users/enum.go @@ -0,0 +1,69 @@ +package users + +import ( + "github.com/CompassSecurity/pipeleek/pkg/gitlab/util" + "github.com/rs/zerolog/log" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +func RunEnum(gitlabURL, token string) { + git, err := util.GetGitlabClient(token, gitlabURL) + if err != nil { + log.Fatal().Stack().Err(err).Msg("Failed creating gitlab client") + } + + log.Info().Msg("Enumerating GitLab users") + + totalUsers := 0 + page := 1 + for page != -1 { + users, nextPage, err := listUsers(git, page) + if err != nil { + log.Fatal().Stack().Err(err).Msg("Failed listing GitLab users") + } + + for _, user := range users { + if user == nil { + continue + } + + totalUsers++ + log.Warn(). + Int64("id", user.ID). + Str("username", user.Username). + Str("name", user.Name). + Str("publicEmail", user.PublicEmail). + Str("profile", user.WebURL). + Str("state", user.State). + Bool("bot", user.Bot). + Bool("admin", user.IsAdmin). + Bool("external", user.External). + Bool("privateProfile", user.PrivateProfile). + Msg("GitLab user") + log.Debug().Interface("full_user", user).Msg("Full User details") + } + + page = nextPage + } + + log.Info().Int("users", totalUsers).Msg("GitLab user enumeration complete") +} + +func listUsers(git *gitlab.Client, page int) ([]*gitlab.User, int, error) { + users, resp, err := git.Users.ListUsers(&gitlab.ListUsersOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 100, + Page: int64(page), + }, + }) + if err != nil { + return nil, -1, err + } + + nextPage := -1 + if resp != nil && resp.NextPage > 0 { + nextPage = int(resp.NextPage) + } + + return users, nextPage, nil +} diff --git a/tests/e2e/gitlab/unauth/users/enum_test.go b/tests/e2e/gitlab/unauth/users/enum_test.go new file mode 100644 index 00000000..df38484f --- /dev/null +++ b/tests/e2e/gitlab/unauth/users/enum_test.go @@ -0,0 +1,53 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/CompassSecurity/pipeleek/tests/e2e/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitLabUnauthenticatedUsersEnum(t *testing.T) { + server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path != "/api/v4/users" { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) + return + } + + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": 7, + "username": "public-user", + "name": "Public User", + "web_url": "http://" + r.Host + "/public-user", + "state": "active", + }}) + }) + defer cleanup() + + stdout, stderr, exitErr := testutil.RunCLI(t, []string{ + "gluna", "users", "enum", + "--gitlab", server.URL, + }, nil, 15*time.Second) + + require.NoError(t, exitErr) + + requests := getRequests() + require.Len(t, requests, 1) + assert.Equal(t, "/api/v4/users", requests[0].Path) + assert.Empty(t, requests[0].Headers.Get("PRIVATE-TOKEN")) + assert.Contains(t, stdout, "GitLab user") + assert.Contains(t, stdout, "public-user") + + t.Logf("STDOUT:\n%s", stdout) + t.Logf("STDERR:\n%s", stderr) +} diff --git a/tests/e2e/gitlab/users/enum_test.go b/tests/e2e/gitlab/users/enum_test.go new file mode 100644 index 00000000..71e28825 --- /dev/null +++ b/tests/e2e/gitlab/users/enum_test.go @@ -0,0 +1,78 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/CompassSecurity/pipeleek/tests/e2e/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitLabUsersEnum(t *testing.T) { + server, getRequests, cleanup := testutil.StartMockServerWithRecording(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path != "/api/v4/users" { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) + return + } + + switch r.URL.Query().Get("page") { + case "", "1": + w.Header().Set("X-Next-Page", "2") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": 1, + "username": "alice", + "name": "Alice Example", + "public_email": "alice@example.com", + "web_url": "http://" + r.Host + "/alice", + "state": "active", + }}) + case "2": + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": 2, + "username": "bob", + "name": "Bob Example", + "web_url": "http://" + r.Host + "/bob", + "state": "blocked", + "bot": true, + }}) + default: + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]map[string]any{}) + } + }) + defer cleanup() + + stdout, stderr, exitErr := testutil.RunCLI(t, []string{ + "gl", "users", "enum", + "--gitlab", server.URL, + "--token", "glpat-test", + }, nil, 15*time.Second) + + require.NoError(t, exitErr) + + requests := getRequests() + require.Len(t, requests, 2) + assert.Equal(t, "/api/v4/users", requests[0].Path) + assert.Equal(t, "glpat-test", requests[0].Headers.Get("PRIVATE-TOKEN")) + assert.Contains(t, requests[0].RawQuery, "page=1") + assert.Contains(t, requests[1].RawQuery, "page=2") + + assert.Contains(t, stdout, "Enumerating GitLab users") + assert.Contains(t, stdout, "GitLab user") + assert.Contains(t, stdout, "alice") + assert.Contains(t, stdout, "bob") + assert.Contains(t, stdout, "GitLab user enumeration complete") + + t.Logf("STDOUT:\n%s", stdout) + t.Logf("STDERR:\n%s", stderr) +} From bf9f2f0393cc76c3fae8c7de3a4bd53ca8144402 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 09:09:40 +0000 Subject: [PATCH 3/4] Refine GitLab users enum logging Agent-Logs-Url: https://github.com/CompassSecurity/pipeleek/sessions/1c0daa5a-30ad-4c4c-a4df-03b95c0ab3b0 Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- internal/cmd/gitlab/users/enum_test.go | 79 ++++++++++++++++++++++++++ pkg/gitlab/users/enum.go | 2 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/gitlab/users/enum_test.go diff --git a/internal/cmd/gitlab/users/enum_test.go b/internal/cmd/gitlab/users/enum_test.go new file mode 100644 index 00000000..ebabe1a5 --- /dev/null +++ b/internal/cmd/gitlab/users/enum_test.go @@ -0,0 +1,79 @@ +package users + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/CompassSecurity/pipeleek/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEnumCommand_WithToken(t *testing.T) { + t.Setenv("PIPELEEK_NO_CONFIG", "1") + config.ResetViper() + t.Cleanup(config.ResetViper) + + var ( + mu sync.Mutex + requests []*http.Request + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Clone(r.Context())) + mu.Unlock() + + require.Equal(t, "/api/v4/users", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]map[string]any{}) + })) + defer server.Close() + + cmd := NewEnumCmd() + cmd.SetArgs([]string{"--gitlab", server.URL, "--token", "glpat-test"}) + + require.NoError(t, cmd.Execute()) + + mu.Lock() + defer mu.Unlock() + require.Len(t, requests, 1) + assert.Equal(t, "glpat-test", requests[0].Header.Get("PRIVATE-TOKEN")) +} + +func TestEnumCommand_WithoutToken(t *testing.T) { + t.Setenv("PIPELEEK_NO_CONFIG", "1") + config.ResetViper() + t.Cleanup(config.ResetViper) + + var ( + mu sync.Mutex + requests []*http.Request + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Clone(r.Context())) + mu.Unlock() + + require.Equal(t, "/api/v4/users", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode([]map[string]any{}) + })) + defer server.Close() + + cmd := NewEnumCmd() + cmd.SetArgs([]string{"--gitlab", server.URL}) + + require.NoError(t, cmd.Execute()) + + mu.Lock() + defer mu.Unlock() + require.Len(t, requests, 1) + assert.Empty(t, requests[0].Header.Get("PRIVATE-TOKEN")) +} diff --git a/pkg/gitlab/users/enum.go b/pkg/gitlab/users/enum.go index 6a5abe8c..9775df43 100644 --- a/pkg/gitlab/users/enum.go +++ b/pkg/gitlab/users/enum.go @@ -28,7 +28,7 @@ func RunEnum(gitlabURL, token string) { } totalUsers++ - log.Warn(). + log.Info(). Int64("id", user.ID). Str("username", user.Username). Str("name", user.Name). From a25bd637adfcaa9116a07346f9fb652343d8519d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 09:11:44 +0000 Subject: [PATCH 4/4] Use int64 paging for GitLab users enum Agent-Logs-Url: https://github.com/CompassSecurity/pipeleek/sessions/1c0daa5a-30ad-4c4c-a4df-03b95c0ab3b0 Co-authored-by: frjcomp <107982661+frjcomp@users.noreply.github.com> --- pkg/gitlab/users/enum.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/gitlab/users/enum.go b/pkg/gitlab/users/enum.go index 9775df43..65fffa64 100644 --- a/pkg/gitlab/users/enum.go +++ b/pkg/gitlab/users/enum.go @@ -15,7 +15,7 @@ func RunEnum(gitlabURL, token string) { log.Info().Msg("Enumerating GitLab users") totalUsers := 0 - page := 1 + page := int64(1) for page != -1 { users, nextPage, err := listUsers(git, page) if err != nil { @@ -49,20 +49,20 @@ func RunEnum(gitlabURL, token string) { log.Info().Int("users", totalUsers).Msg("GitLab user enumeration complete") } -func listUsers(git *gitlab.Client, page int) ([]*gitlab.User, int, error) { +func listUsers(git *gitlab.Client, page int64) ([]*gitlab.User, int64, error) { users, resp, err := git.Users.ListUsers(&gitlab.ListUsersOptions{ ListOptions: gitlab.ListOptions{ PerPage: 100, - Page: int64(page), + Page: page, }, }) if err != nil { return nil, -1, err } - nextPage := -1 + nextPage := int64(-1) if resp != nil && resp.NextPage > 0 { - nextPage = int(resp.NextPage) + nextPage = resp.NextPage } return users, nextPage, nil