Skip to content

Commit 9d9fff0

Browse files
authored
Add unauthenticated GitLab public scan (#619)
* Add unauthenticated GitLab public scan
1 parent be728c9 commit 9d9fff0

11 files changed

Lines changed: 598 additions & 16 deletions

File tree

docs/guides/gitlab.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ See if you can already identify potentially sensitive data e.g. credentials in s
2525
The next step would be to try to create an account. Head to `https://leakycompany.com/users/sign_up` and try to register a new account.
2626
Sometimes you can only create an account with an email address managed by the customer, some instances require the admins to accept the register request, and others completely disable it.
2727

28+
## Anonymous Secret Scan
29+
30+
If there is no possibility to register an account and perform an authenticated secrets scan, you can still scan all publicly available CI/CD logs using Pipeleek's unauthenticated mode.
31+
32+
Prefer the authenticated scan over the unauthenticated one whenever possible, as it provides broader coverage.
33+
34+
```bash
35+
# Scan all publicly accessible CI/CD logs, including artifacts (breadth-first)
36+
pipeleek gluna scan -g https://leakycompany.com -a --job-limit 10
37+
```
38+
2839
## Authenticated Access
2940

3041
Sweet now you have access to the GitLab instance with an account.

docs/introduction/configuration.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,20 @@ gitlab:
107107
privesc: {} # gl renovate privesc (inherits url/token)
108108

109109
register:
110-
username: newuser # gl register --username
111-
password: secret # gl register --password
112-
email: user@example.com # gl register --email
110+
username: newuser # gluna register --username
111+
password: secret # gluna register --password
112+
email: user@example.com # gluna register --email
113113

114114
shodan:
115-
json: shodan_data.json # gl shodan --json
115+
json: shodan_data.json # gluna shodan --json
116+
117+
scan_public:
118+
search: "" # gluna scan --search
119+
repo: "" # gluna scan --repo
120+
namespace: "" # gluna scan --namespace
121+
job_limit: 0 # gluna scan --job-limit
122+
queue: "" # gluna scan --queue
123+
artifacts: false # gluna scan --artifacts
116124

117125
scan:
118126
threads: 10 # gl scan --threads (can override common.threads)

internal/cmd/gitlab/gitlab_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/enum"
77
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/register"
8+
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/scanpublic"
89
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/shodan"
910
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/snippets"
1011
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/variables"
@@ -106,3 +107,36 @@ func TestNewSnippetsRootCmd(t *testing.T) {
106107
require.NotNil(t, scanCmd)
107108
assert.Equal(t, "scan", scanCmd.Name())
108109
}
110+
111+
func TestNewGitLabRootUnauthenticatedCmd(t *testing.T) {
112+
cmd := NewGitLabRootUnauthenticatedCmd()
113+
114+
require.NotNil(t, cmd)
115+
assert.Equal(t, "gluna [command]", cmd.Use)
116+
assert.Equal(t, "Helper", cmd.GroupID)
117+
118+
shodanCmd, _, err := cmd.Find([]string{"shodan"})
119+
require.NoError(t, err)
120+
assert.NotNil(t, shodanCmd)
121+
122+
registerCmd, _, err := cmd.Find([]string{"register"})
123+
require.NoError(t, err)
124+
assert.NotNil(t, registerCmd)
125+
126+
publicScanCmd, _, err := cmd.Find([]string{"scan"})
127+
require.NoError(t, err)
128+
assert.NotNil(t, publicScanCmd)
129+
}
130+
131+
func TestNewScanPublicCmd(t *testing.T) {
132+
cmd := scanpublic.NewScanPublicCmd()
133+
134+
require.NotNil(t, cmd)
135+
assert.Equal(t, "scan", cmd.Use)
136+
assert.NotEmpty(t, cmd.Short)
137+
138+
flags := cmd.Flags()
139+
assert.NotNil(t, flags.Lookup("repo"), "'repo' flag should be registered")
140+
assert.NotNil(t, flags.Lookup("namespace"), "'namespace' flag should be registered")
141+
assert.NotNil(t, flags.Lookup("search"), "'search' flag should be registered")
142+
}

internal/cmd/gitlab/gitlab_unauth.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package gitlab
22

33
import (
44
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/register"
5+
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/scanpublic"
56
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab/shodan"
67
"github.com/spf13/cobra"
78
)
@@ -16,6 +17,7 @@ func NewGitLabRootUnauthenticatedCmd() *cobra.Command {
1617

1718
glunaCmd.AddCommand(shodan.NewShodanCmd())
1819
glunaCmd.AddCommand(register.NewRegisterCmd())
20+
glunaCmd.AddCommand(scanpublic.NewScanPublicCmd())
1921

2022
return glunaCmd
2123
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package scanpublic
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/CompassSecurity/pipeleek/internal/cmd/flags"
8+
"github.com/CompassSecurity/pipeleek/pkg/config"
9+
gitlabscan "github.com/CompassSecurity/pipeleek/pkg/gitlab/scan"
10+
"github.com/CompassSecurity/pipeleek/pkg/logging"
11+
"github.com/rs/zerolog"
12+
"github.com/rs/zerolog/log"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
type ScanPublicOptions struct {
17+
config.CommonScanOptions
18+
ProjectSearchQuery string
19+
Repository string
20+
Namespace string
21+
JobLimit int
22+
QueueFolder string
23+
}
24+
25+
var options = ScanPublicOptions{
26+
CommonScanOptions: config.DefaultCommonScanOptions(),
27+
}
28+
29+
var maxArtifactSize string
30+
31+
func NewScanPublicCmd() *cobra.Command {
32+
scanCmd := &cobra.Command{
33+
Use: "scan",
34+
Short: "Scan public GitLab pipelines without an account",
35+
Long: `Scan public GitLab project pipelines for secrets in job traces and optionally artifacts.
36+
37+
This command does not require an API token and only covers resources that are publicly accessible.
38+
Dotenv artifacts are intentionally not scanned in this mode because they require a UI session cookie.`,
39+
Example: `
40+
# Scan public project pipelines and traces
41+
pipeleek gluna scan --gitlab https://gitlab.example.com
42+
43+
# Scan public pipelines with artifacts and tuned performance
44+
pipeleek gluna scan --gitlab https://gitlab.example.com --artifacts --job-limit 10 --max-artifact-size 200Mb --threads 8
45+
46+
# Scan one public repository
47+
pipeleek gluna scan --gitlab https://gitlab.example.com --repo mygroup/myproject
48+
49+
# Scan all public repositories in a namespace
50+
pipeleek gluna scan --gitlab https://gitlab.example.com --namespace mygroup
51+
`,
52+
Run: ScanPublic,
53+
}
54+
55+
scanCmd.Flags().StringP("gitlab", "g", "", "GitLab instance URL")
56+
flags.AddCommonScanFlagsNoArtifacts(scanCmd, &options.CommonScanOptions)
57+
scanCmd.Flags().BoolVarP(&options.Artifacts, "artifacts", "a", false, "Scan artifacts")
58+
scanCmd.Flags().StringVarP(&maxArtifactSize, "max-artifact-size", "", "500Mb",
59+
"Maximum artifact size to scan. Larger files are skipped. Format: https://pkg.go.dev/github.com/docker/go-units#FromHumanSize")
60+
scanCmd.Flags().StringVarP(&options.ProjectSearchQuery, "search", "s", "", "Query string for searching public projects")
61+
scanCmd.Flags().StringVarP(&options.Repository, "repo", "r", "", "Single public repository to scan, format: namespace/repo")
62+
scanCmd.Flags().StringVarP(&options.Namespace, "namespace", "n", "", "Namespace to scan (all public repos in the namespace will be scanned)")
63+
scanCmd.Flags().IntVarP(&options.JobLimit, "job-limit", "j", 0, "Scan a max number of pipeline jobs - trade speed vs coverage. 0 scans all and is the default.")
64+
scanCmd.Flags().StringVarP(&options.QueueFolder, "queue", "q", "", "Relative or absolute folderpath where the queue files will be stored. Defaults to system tmp. Non-existing folders will be created.")
65+
66+
return scanCmd
67+
}
68+
69+
func ScanPublic(cmd *cobra.Command, args []string) {
70+
if err := config.AutoBindFlags(cmd, map[string]string{
71+
"gitlab": "gitlab.url",
72+
"search": "gitlab.scan_public.search",
73+
"repo": "gitlab.scan_public.repo",
74+
"namespace": "gitlab.scan_public.namespace",
75+
"job-limit": "gitlab.scan_public.job_limit",
76+
"queue": "gitlab.scan_public.queue",
77+
"artifacts": "gitlab.scan_public.artifacts",
78+
"threads": "common.threads",
79+
"truffle-hog-verification": "common.trufflehog_verification",
80+
"max-artifact-size": "common.max_artifact_size",
81+
"confidence": "common.confidence_filter",
82+
"hit-timeout": "common.hit_timeout",
83+
}); err != nil {
84+
log.Fatal().Err(err).Msg("Failed to bind command flags to configuration keys")
85+
}
86+
87+
if err := config.RequireConfigKeys("gitlab.url"); err != nil {
88+
log.Fatal().Err(err).Msg("required configuration missing")
89+
}
90+
91+
gitlabURL := config.GetString("gitlab.url")
92+
projectSearchQuery := config.GetString("gitlab.scan_public.search")
93+
repository := config.GetString("gitlab.scan_public.repo")
94+
namespace := config.GetString("gitlab.scan_public.namespace")
95+
jobLimit := config.GetInt("gitlab.scan_public.job_limit")
96+
queueFolder := config.GetString("gitlab.scan_public.queue")
97+
artifacts := config.GetBool("gitlab.scan_public.artifacts")
98+
threads := config.GetInt("common.threads")
99+
truffleHogVerification := config.GetBool("common.trufflehog_verification")
100+
maxArtifactSize = config.GetString("common.max_artifact_size")
101+
confidenceFilter := config.GetStringSlice("common.confidence_filter")
102+
hitTimeoutRaw := config.GetString("common.hit_timeout")
103+
hitTimeout, err := time.ParseDuration(hitTimeoutRaw)
104+
if err != nil {
105+
log.Fatal().Err(fmt.Errorf("invalid hit-timeout %q: %w", hitTimeoutRaw, err)).Msg("Invalid hit timeout")
106+
}
107+
108+
if err := config.ValidateURL(gitlabURL, "GitLab URL"); err != nil {
109+
log.Fatal().Err(err).Msg("Invalid GitLab URL")
110+
}
111+
if err := config.ValidateThreadCount(threads); err != nil {
112+
log.Fatal().Err(err).Msg("Invalid thread count")
113+
}
114+
115+
scanOpts, err := gitlabscan.InitializeOptions(
116+
gitlabURL,
117+
"",
118+
"",
119+
projectSearchQuery,
120+
repository,
121+
namespace,
122+
queueFolder,
123+
maxArtifactSize,
124+
artifacts,
125+
false,
126+
false,
127+
truffleHogVerification,
128+
jobLimit,
129+
threads,
130+
confidenceFilter,
131+
hitTimeout,
132+
)
133+
if err != nil {
134+
log.Fatal().Err(err).Msg("Failed initializing public scan options")
135+
}
136+
137+
scanner := gitlabscan.NewScanner(scanOpts)
138+
logging.RegisterStatusHook(func() *zerolog.Event {
139+
queueLength := scanner.GetQueueStatus()
140+
return log.Info().Int("pendingjobs", queueLength)
141+
})
142+
143+
if err := scanner.Scan(); err != nil {
144+
log.Fatal().Err(err).Msg("Public scan failed")
145+
}
146+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package scanpublic
2+
3+
import (
4+
"testing"
5+
6+
"github.com/CompassSecurity/pipeleek/pkg/config"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestNewScanPublicCmd(t *testing.T) {
12+
cmd := NewScanPublicCmd()
13+
14+
require.NotNil(t, cmd)
15+
assert.Equal(t, "scan", cmd.Use)
16+
assert.NotEmpty(t, cmd.Short)
17+
assert.Contains(t, cmd.Long, "does not require an API token")
18+
assert.Contains(t, cmd.Example, "gluna scan --gitlab")
19+
20+
flags := cmd.Flags()
21+
assert.NotNil(t, flags.Lookup("search"))
22+
assert.NotNil(t, flags.Lookup("repo"))
23+
assert.NotNil(t, flags.Lookup("namespace"))
24+
assert.NotNil(t, flags.Lookup("job-limit"))
25+
assert.NotNil(t, flags.Lookup("queue"))
26+
assert.NotNil(t, flags.Lookup("artifacts"))
27+
assert.Nil(t, flags.Lookup("owned"), "'owned' flag must not be present on public scan")
28+
assert.NotNil(t, flags.Lookup("threads"))
29+
assert.NotNil(t, flags.Lookup("truffle-hog-verification"))
30+
assert.NotNil(t, flags.Lookup("confidence"))
31+
assert.NotNil(t, flags.Lookup("hit-timeout"))
32+
33+
assert.Equal(t, "r", flags.Lookup("repo").Shorthand)
34+
assert.Equal(t, "n", flags.Lookup("namespace").Shorthand)
35+
assert.Equal(t, "s", flags.Lookup("search").Shorthand)
36+
assert.Equal(t, "j", flags.Lookup("job-limit").Shorthand)
37+
assert.Equal(t, "q", flags.Lookup("queue").Shorthand)
38+
39+
assert.Equal(t, "0", flags.Lookup("job-limit").DefValue)
40+
assert.Equal(t, "", flags.Lookup("repo").DefValue)
41+
assert.Equal(t, "", flags.Lookup("namespace").DefValue)
42+
assert.Equal(t, "", flags.Lookup("search").DefValue)
43+
44+
defaults := config.DefaultCommonScanOptions()
45+
assert.Equal(t, defaults.TruffleHogVerification, cmd.Flags().Lookup("truffle-hog-verification").DefValue == "true")
46+
}

pipeleek.example.yaml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,25 @@ gitlab:
9090
bots:
9191
term: "renovate" # Search term for identifying potential renovate bot users
9292

93-
# register - Register new user account
93+
# register - Register new user account (gluna register)
9494
register:
9595
username: "newuser"
9696
password: "securepassword"
9797
email: "newuser@example.com"
9898

99-
# shodan - Query Shodan for GitLab instances
99+
# shodan - Query Shodan for GitLab instances (gluna shodan)
100100
shodan:
101101
json: "shodan_data.json" # Path to Shodan JSON export
102102

103+
# scan - Scan public pipelines without account or token (gluna scan)
104+
scan_public:
105+
search: "" # Optional project search query
106+
repo: "" # Optional single repository namespace/project
107+
namespace: "" # Optional namespace/group to scan
108+
job_limit: 0 # Max jobs per project; 0 scans all
109+
queue: "" # Optional queue folder path
110+
artifacts: false # Scan artifacts in addition to job traces
111+
103112
# scan - Scan CI/CD artifacts for secrets
104113
scan:
105114
# Inherits common.* settings, can override per-command

0 commit comments

Comments
 (0)