Skip to content

Commit d413d57

Browse files
authored
feat(jenkins): add Jenkins platform scanner and CLI (#577)
* feat(jenkins): add Jenkins platform scanner and CLI
1 parent 60505fa commit d413d57

17 files changed

Lines changed: 990 additions & 1 deletion

File tree

cmd/pipeleek-jenkins/main.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package main
2+
3+
import (
4+
"github.com/CompassSecurity/pipeleek/internal/cmd/common"
5+
"github.com/CompassSecurity/pipeleek/internal/cmd/jenkins"
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func main() {
10+
common.Run(newRootCmd())
11+
}
12+
13+
func newRootCmd() *cobra.Command {
14+
jenkinsCmd := jenkins.NewJenkinsRootCmd()
15+
jenkinsCmd.Use = "pipeleek-jenkins"
16+
jenkinsCmd.Short = "Scan Jenkins logs and artifacts for secrets"
17+
jenkinsCmd.Long = `Pipeleek-Jenkins scans Jenkins logs and artifacts to detect leaked secrets and pivot from them.`
18+
jenkinsCmd.Version = common.Version
19+
jenkinsCmd.GroupID = ""
20+
21+
common.SetupPersistentPreRun(jenkinsCmd)
22+
common.AddCommonFlags(jenkinsCmd)
23+
24+
jenkinsCmd.SetVersionTemplate(`{{.Version}}
25+
`)
26+
27+
return jenkinsCmd
28+
}

docs/introduction/configuration.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ gitlab:
116116

117117
scan:
118118
threads: 10 # gl scan --threads (can override common.threads)
119-
119+
120120
tf:
121121
output_dir: ./terraform-states # gl tf --output-dir
122122
threads: 4 # gl tf --threads (can override common.threads)
@@ -186,6 +186,20 @@ gitea:
186186
repo: myrepo # gitea scan --repo (optional, scans all if not specified)
187187
```
188188
189+
### Jenkins
190+
191+
```yaml
192+
jenkins:
193+
url: https://jenkins.example.com
194+
username: admin
195+
token: jenkins-api-token
196+
197+
scan:
198+
folder: team-a # jenkins scan --folder (optional)
199+
job: team-a/service-a # jenkins scan --job (optional)
200+
max_builds: 25 # jenkins scan --max-builds
201+
```
202+
189203
### Common Settings
190204
191205
Scan commands inherit from `common`:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
atomicgo.dev/keyboard v0.2.9
77
code.gitea.io/sdk/gitea v0.24.1
88
github.com/PuerkitoBio/goquery v1.12.0
9+
github.com/bndr/gojenkins v1.2.0
910
github.com/docker/go-units v0.5.0
1011
github.com/go-git/go-git/v5 v5.17.1
1112
github.com/gofri/go-github-ratelimit/v2 v2.0.2

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
104104
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
105105
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
106106
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
107+
github.com/bndr/gojenkins v1.2.0 h1:iomz/HKK5HlmQ1a65Qc3ejd6i1z6MtcyI85GZYetMxI=
108+
github.com/bndr/gojenkins v1.2.0/go.mod h1:Mzk6d2aV+fzE3UIEIohi59Lh20cfRiWdFztx/AQbizM=
107109
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
108110
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
109111
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=

goreleaser.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,27 @@ builds:
127127
- -X github.com/CompassSecurity/pipeleek/internal/cmd/common.Version={{.Version}}
128128
- -X github.com/CompassSecurity/pipeleek/internal/cmd/common.Commit={{.Commit}}
129129
- -X github.com/CompassSecurity/pipeleek/internal/cmd/common.Date={{.Date}}
130+
- id: "pipeleek-jenkins"
131+
skip: "{{ .IsSnapshot }}"
132+
main: ./cmd/pipeleek-jenkins
133+
binary: pipeleek-jenkins
134+
env:
135+
- CGO_ENABLED=0
136+
flags:
137+
- -trimpath
138+
goos:
139+
- linux
140+
- windows
141+
- darwin
142+
goarch:
143+
- amd64
144+
- arm64
145+
ldflags:
146+
- -s -w
147+
- -buildid=
148+
- -X github.com/CompassSecurity/pipeleek/internal/cmd/common.Version={{.Version}}
149+
- -X github.com/CompassSecurity/pipeleek/internal/cmd/common.Commit={{.Commit}}
150+
- -X github.com/CompassSecurity/pipeleek/internal/cmd/common.Date={{.Date}}
130151

131152
archives:
132153
- formats: binary

internal/cmd/flags/common.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ func AddCommonScanFlags(cmd *cobra.Command, opts *config.CommonScanOptions, maxA
2929
cmd.Flags().BoolVarP(&opts.Owned, "owned", "o", false, "Scan only user owned repositories")
3030
}
3131

32+
// AddCommonScanFlagsNoOwned adds standard scan flags including artifacts but excluding the
33+
// --owned flag, for platforms that have no concept of job/repository ownership (e.g. Jenkins).
34+
func AddCommonScanFlagsNoOwned(cmd *cobra.Command, opts *config.CommonScanOptions, maxArtifactSize *string) {
35+
addBaseScanFlags(cmd, opts)
36+
cmd.Flags().BoolVarP(&opts.Artifacts, "artifacts", "a", false, "Scan artifacts")
37+
cmd.Flags().StringVarP(maxArtifactSize, "max-artifact-size", "", "500Mb",
38+
"Maximum artifact size to scan. Larger files are skipped. Format: https://pkg.go.dev/github.com/docker/go-units#FromHumanSize")
39+
}
40+
3241
// AddCommonScanFlagsNoArtifacts adds standard scan flags excluding artifact and ownership filters.
3342
func AddCommonScanFlagsNoArtifacts(cmd *cobra.Command, opts *config.CommonScanOptions) {
3443
addBaseScanFlags(cmd, opts)

internal/cmd/jenkins/jenkins.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package jenkins
2+
3+
import (
4+
"github.com/CompassSecurity/pipeleek/internal/cmd/jenkins/scan"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func NewJenkinsRootCmd() *cobra.Command {
9+
jenkinsCmd := &cobra.Command{
10+
Use: "jenkins [command]",
11+
Short: "Jenkins related commands",
12+
GroupID: "Jenkins",
13+
}
14+
15+
jenkinsCmd.AddCommand(scan.NewScanCmd())
16+
17+
return jenkinsCmd
18+
}

internal/cmd/jenkins/scan/scan.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package scan
2+
3+
import (
4+
"github.com/CompassSecurity/pipeleek/internal/cmd/flags"
5+
"github.com/CompassSecurity/pipeleek/pkg/config"
6+
jenkinsscan "github.com/CompassSecurity/pipeleek/pkg/jenkins/scan"
7+
"github.com/CompassSecurity/pipeleek/pkg/logging"
8+
"github.com/rs/zerolog"
9+
"github.com/rs/zerolog/log"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
type JenkinsScanOptions struct {
14+
config.CommonScanOptions
15+
JenkinsURL string
16+
Username string
17+
Token string
18+
Folder string
19+
Job string
20+
MaxBuilds int
21+
}
22+
23+
var options = JenkinsScanOptions{
24+
CommonScanOptions: config.DefaultCommonScanOptions(),
25+
}
26+
27+
var maxArtifactSize string
28+
29+
func NewScanCmd() *cobra.Command {
30+
scanCmd := &cobra.Command{
31+
Use: "scan",
32+
Short: "Scan Jenkins jobs",
33+
Long: `Scan Jenkins job logs, artifacts, job definitions, and exposed environment variables for secrets.`,
34+
Example: `
35+
# Scan all accessible jobs on the Jenkins instance
36+
pipeleek jenkins scan --jenkins https://jenkins.example.com --username admin --token token_value
37+
38+
# Scan only a folder recursively
39+
pipeleek jenkins scan --jenkins https://jenkins.example.com --username admin --token token_value --folder team-a
40+
41+
# Scan one specific job path
42+
pipeleek jenkins scan --jenkins https://jenkins.example.com --username admin --token token_value --job team-a/service-a
43+
44+
# Limit builds per job and include artifacts
45+
pipeleek jenkins scan --jenkins https://jenkins.example.com --username admin --token token_value --max-builds 20 --artifacts
46+
`,
47+
Run: Scan,
48+
}
49+
50+
flags.AddCommonScanFlagsNoOwned(scanCmd, &options.CommonScanOptions, &maxArtifactSize)
51+
scanCmd.Flags().StringVarP(&options.JenkinsURL, "jenkins", "j", "", "Jenkins base URL")
52+
scanCmd.Flags().StringVarP(&options.Username, "username", "u", "", "Jenkins username")
53+
scanCmd.Flags().StringVarP(&options.Token, "token", "t", "", "Jenkins API token")
54+
scanCmd.Flags().StringVarP(&options.Folder, "folder", "f", "", "Jenkins folder path to scan recursively (e.g. team-a/platform)")
55+
scanCmd.Flags().StringVarP(&options.Job, "job", "", "", "Specific Jenkins job path to scan (e.g. team-a/service-a)")
56+
scanCmd.Flags().IntVarP(&options.MaxBuilds, "max-builds", "", 25, "Maximum builds to scan per job (0 = all builds)")
57+
scanCmd.MarkFlagsMutuallyExclusive("folder", "job")
58+
59+
return scanCmd
60+
}
61+
62+
func Scan(cmd *cobra.Command, args []string) {
63+
if err := config.AutoBindFlags(cmd, map[string]string{
64+
"jenkins": "jenkins.url",
65+
"username": "jenkins.username",
66+
"token": "jenkins.token",
67+
"folder": "jenkins.scan.folder",
68+
"job": "jenkins.scan.job",
69+
"max-builds": "jenkins.scan.max_builds",
70+
"threads": "common.threads",
71+
"truffle-hog-verification": "common.trufflehog_verification",
72+
"max-artifact-size": "common.max_artifact_size",
73+
"confidence": "common.confidence_filter",
74+
"hit-timeout": "common.hit_timeout",
75+
}); err != nil {
76+
log.Fatal().Err(err).Msg("Failed to bind command flags to configuration keys")
77+
}
78+
79+
if err := config.RequireConfigKeys("jenkins.url", "jenkins.username", "jenkins.token"); err != nil {
80+
log.Fatal().Err(err).Msg("required configuration missing")
81+
}
82+
83+
options.JenkinsURL = config.GetString("jenkins.url")
84+
options.Username = config.GetString("jenkins.username")
85+
options.Token = config.GetString("jenkins.token")
86+
options.Folder = config.GetString("jenkins.scan.folder")
87+
options.Job = config.GetString("jenkins.scan.job")
88+
options.MaxBuilds = config.GetInt("jenkins.scan.max_builds")
89+
options.MaxScanGoRoutines = config.GetInt("common.threads")
90+
options.TruffleHogVerification = config.GetBool("common.trufflehog_verification")
91+
maxArtifactSize = config.GetString("common.max_artifact_size")
92+
options.ConfidenceFilter = config.GetStringSlice("common.confidence_filter")
93+
94+
if err := config.ValidateURL(options.JenkinsURL, "Jenkins URL"); err != nil {
95+
log.Fatal().Err(err).Msg("Invalid Jenkins URL")
96+
}
97+
if err := config.ValidateToken(options.Username, "Jenkins Username"); err != nil {
98+
log.Fatal().Err(err).Msg("Invalid Jenkins Username")
99+
}
100+
if err := config.ValidateToken(options.Token, "Jenkins API Token"); err != nil {
101+
log.Fatal().Err(err).Msg("Invalid Jenkins API Token")
102+
}
103+
if err := config.ValidateThreadCount(options.MaxScanGoRoutines); err != nil {
104+
log.Fatal().Err(err).Msg("Invalid thread count")
105+
}
106+
107+
scanOpts, err := jenkinsscan.InitializeOptions(
108+
options.Username,
109+
options.Token,
110+
options.JenkinsURL,
111+
options.Folder,
112+
options.Job,
113+
maxArtifactSize,
114+
options.Artifacts,
115+
options.TruffleHogVerification,
116+
options.MaxBuilds,
117+
options.MaxScanGoRoutines,
118+
options.ConfidenceFilter,
119+
options.HitTimeout,
120+
)
121+
if err != nil {
122+
log.Fatal().Err(err).Msg("Failed initializing scan options")
123+
}
124+
125+
scanner := jenkinsscan.NewScanner(scanOpts)
126+
logging.RegisterStatusHook(func() *zerolog.Event { return scanner.Status() })
127+
128+
if err := scanner.Scan(); err != nil {
129+
log.Fatal().Err(err).Msg("Scan failed")
130+
}
131+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package scan
2+
3+
import (
4+
"testing"
5+
6+
"github.com/CompassSecurity/pipeleek/pkg/config"
7+
)
8+
9+
func TestNewScanCmd(t *testing.T) {
10+
cmd := NewScanCmd()
11+
if cmd == nil {
12+
t.Fatal("expected non-nil command")
13+
}
14+
15+
if cmd.Use != "scan" {
16+
t.Fatalf("expected use 'scan', got %q", cmd.Use)
17+
}
18+
19+
flags := cmd.Flags()
20+
for _, name := range []string{
21+
"jenkins",
22+
"username",
23+
"token",
24+
"folder",
25+
"job",
26+
"max-builds",
27+
"threads",
28+
"truffle-hog-verification",
29+
"confidence",
30+
"artifacts",
31+
"max-artifact-size",
32+
} {
33+
if flags.Lookup(name) == nil {
34+
t.Errorf("expected flag %q to exist", name)
35+
}
36+
}
37+
}
38+
39+
func TestJenkinsScanOptions(t *testing.T) {
40+
opts := JenkinsScanOptions{
41+
CommonScanOptions: config.CommonScanOptions{
42+
ConfidenceFilter: []string{"high"},
43+
MaxScanGoRoutines: 5,
44+
TruffleHogVerification: true,
45+
Artifacts: true,
46+
},
47+
JenkinsURL: "https://jenkins.example.com",
48+
Username: "admin",
49+
Token: "apitoken",
50+
Folder: "team-a",
51+
Job: "",
52+
MaxBuilds: 10,
53+
}
54+
55+
if opts.JenkinsURL == "" || opts.Username == "" || opts.Token == "" {
56+
t.Fatal("expected required Jenkins fields to be populated")
57+
}
58+
if opts.MaxBuilds != 10 {
59+
t.Fatalf("expected MaxBuilds=10, got %d", opts.MaxBuilds)
60+
}
61+
if len(opts.ConfidenceFilter) != 1 || opts.ConfidenceFilter[0] != "high" {
62+
t.Fatalf("unexpected confidence filter: %#v", opts.ConfidenceFilter)
63+
}
64+
}

internal/cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/CompassSecurity/pipeleek/internal/cmd/gitea"
1414
"github.com/CompassSecurity/pipeleek/internal/cmd/github"
1515
"github.com/CompassSecurity/pipeleek/internal/cmd/gitlab"
16+
"github.com/CompassSecurity/pipeleek/internal/cmd/jenkins"
1617
"github.com/CompassSecurity/pipeleek/pkg/config"
1718
"github.com/CompassSecurity/pipeleek/pkg/format"
1819
"github.com/CompassSecurity/pipeleek/pkg/httpclient"
@@ -74,6 +75,7 @@ func init() {
7475
rootCmd.AddCommand(bitbucket.NewBitBucketRootCmd())
7576
rootCmd.AddCommand(devops.NewAzureDevOpsRootCmd())
7677
rootCmd.AddCommand(gitea.NewGiteaRootCmd())
78+
rootCmd.AddCommand(jenkins.NewJenkinsRootCmd())
7779
rootCmd.AddCommand(docs.NewDocsCmd(rootCmd))
7880
rootCmd.PersistentFlags().StringVar(&ConfigFile, "config", "", "Config file path. Example: ~/.config/pipeleek/pipeleek.yaml")
7981
rootCmd.PersistentFlags().BoolVarP(&JsonLogoutput, "json", "", false, "Use JSON as log output format")
@@ -93,6 +95,7 @@ func init() {
9395
rootCmd.AddGroup(&cobra.Group{ID: "BitBucket", Title: "BitBucket Commands"})
9496
rootCmd.AddGroup(&cobra.Group{ID: "AzureDevOps", Title: "Azure DevOps Commands"})
9597
rootCmd.AddGroup(&cobra.Group{ID: "Gitea", Title: "Gitea Commands"})
98+
rootCmd.AddGroup(&cobra.Group{ID: "Jenkins", Title: "Jenkins Commands"})
9699
}
97100

98101
type CustomWriter struct {

0 commit comments

Comments
 (0)