-
Notifications
You must be signed in to change notification settings - Fork 3
feat: implement GitLab Terraform state scanner command (gl tf) #480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,102 @@ | ||||||
| package tf | ||||||
|
|
||||||
| import ( | ||||||
| "github.com/CompassSecurity/pipeleek/internal/cmd/flags" | ||||||
| "github.com/CompassSecurity/pipeleek/pkg/config" | ||||||
| tfpkg "github.com/CompassSecurity/pipeleek/pkg/gitlab/tf" | ||||||
| "github.com/rs/zerolog/log" | ||||||
| "github.com/spf13/cobra" | ||||||
| ) | ||||||
|
|
||||||
| type TFCommandOptions struct { | ||||||
| config.CommonScanOptions | ||||||
| OutputDir string | ||||||
| } | ||||||
|
|
||||||
| var options = TFCommandOptions{CommonScanOptions: config.DefaultCommonScanOptions()} | ||||||
| var maxArtifactSize string | ||||||
|
|
||||||
| func NewTFCmd() *cobra.Command { | ||||||
| tfCmd := &cobra.Command{ | ||||||
| Use: "tf", | ||||||
| Short: "Scan Terraform/OpenTofu state files for secrets", | ||||||
| Long: `Scan GitLab Terraform/OpenTofu state files for secrets | ||||||
|
|
||||||
| This command iterates through all projects where you have maintainer access, | ||||||
| checks for Terraform state files stored in GitLab, downloads them locally, | ||||||
| and scans them for secrets using TruffleHog. | ||||||
|
|
||||||
| GitLab stores Terraform state natively when using the Terraform HTTP backend. | ||||||
| Each project can have multiple named state files.`, | ||||||
|
||||||
| Each project can have multiple named state files.`, | |
| While GitLab supports multiple named Terraform state files per project, this command currently scans only the default state for each project.`, |
Copilot
AI
Jan 19, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The AddCommonScanFlags function adds several flags that are not relevant to the Terraform state scanning functionality:
--artifacts(line 16 in flags/common.go): TF state scanning doesn't scan artifacts--max-artifact-size(line 17 in flags/common.go): Not applicable to TF state files--owned(line 21 in flags/common.go): TF command already filters by Maintainer access, making this flag redundant
These flags will appear in pipeleek gl tf --help but won't have any effect, which could confuse users. Consider either:
- Creating a more tailored flag addition function for commands that don't scan artifacts, or
- Documenting which flags are not applicable to this command
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,249 @@ | ||
| package tf | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/CompassSecurity/pipeleek/pkg/gitlab/util" | ||
| "github.com/CompassSecurity/pipeleek/pkg/httpclient" | ||
| "github.com/CompassSecurity/pipeleek/pkg/logging" | ||
| "github.com/CompassSecurity/pipeleek/pkg/scanner" | ||
| "github.com/rs/zerolog/log" | ||
| gitlab "gitlab.com/gitlab-org/api/client-go" | ||
| ) | ||
|
|
||
| type TFOptions struct { | ||
| GitlabUrl string | ||
| GitlabApiToken string | ||
| OutputDir string | ||
| Threads int | ||
| ConfidenceFilter []string | ||
| TruffleHogVerification bool | ||
| HitTimeout time.Duration | ||
| } | ||
|
|
||
| type terraformState struct { | ||
| Name string | ||
| ProjectID int | ||
| Project *gitlab.Project | ||
| } | ||
|
|
||
| // ScanTerraformStates scans all Terraform/OpenTofu state files for secrets | ||
| func ScanTerraformStates(options TFOptions) { | ||
| log.Info().Msg("Starting Terraform state scan") | ||
|
|
||
| // Initialize scanner | ||
| scanner.InitRules(options.ConfidenceFilter) | ||
| if !options.TruffleHogVerification { | ||
| log.Info().Msg("TruffleHog verification is disabled") | ||
| } | ||
|
|
||
| // Create output directory | ||
| if err := os.MkdirAll(options.OutputDir, 0o755); err != nil { | ||
| log.Fatal().Err(err).Str("dir", options.OutputDir).Msg("Failed to create output directory") | ||
| } | ||
|
|
||
| // Initialize GitLab client | ||
| git, err := util.GetGitlabClient(options.GitlabApiToken, options.GitlabUrl) | ||
| if err != nil { | ||
| log.Fatal().Stack().Err(err).Msg("Failed creating gitlab client") | ||
| } | ||
|
|
||
| // Fetch all projects with maintainer access | ||
| states := fetchTerraformStates(git, options.GitlabUrl, options.GitlabApiToken) | ||
| log.Info().Int("total", len(states)).Msg("Found Terraform states") | ||
|
|
||
| if len(states) == 0 { | ||
| log.Warn().Msg("No Terraform states found") | ||
| return | ||
| } | ||
|
|
||
| // Download and scan states with concurrency | ||
| downloadAndScanStates(states, options) | ||
|
|
||
| log.Info().Msg("Terraform state scan complete") | ||
| } | ||
|
|
||
| // fetchTerraformStates iterates all projects and finds those with Terraform state | ||
| func fetchTerraformStates(git *gitlab.Client, gitlabUrl string, token string) []terraformState { | ||
| var states []terraformState | ||
| var mu sync.Mutex | ||
|
|
||
| projectOpts := &gitlab.ListProjectsOptions{ | ||
| ListOptions: gitlab.ListOptions{PerPage: 100, Page: 1}, | ||
| MinAccessLevel: gitlab.Ptr(gitlab.MaintainerPermissions), | ||
| OrderBy: gitlab.Ptr("last_activity_at"), | ||
| } | ||
|
|
||
| log.Info().Msg("Fetching projects with maintainer access") | ||
|
|
||
| err := util.IterateProjects(git, projectOpts, func(project *gitlab.Project) error { | ||
| log.Debug().Str("project", project.PathWithNamespace).Int64("id", project.ID).Msg("Checking project for Terraform state") | ||
|
|
||
| // Check for Terraform state using HTTP API | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. check all created comments in all files. Only keep the ones providing real additional context. Remove all others. |
||
| stateExists := checkTerraformState(gitlabUrl, token, int(project.ID)) | ||
| if stateExists { | ||
| mu.Lock() | ||
| states = append(states, terraformState{ | ||
| Name: "default", | ||
| ProjectID: int(project.ID), | ||
| Project: project, | ||
| }) | ||
| mu.Unlock() | ||
|
|
||
| log.Info().Str("project", project.PathWithNamespace).Msg("Found Terraform state") | ||
| } | ||
| return nil | ||
| }) | ||
|
|
||
| if err != nil { | ||
| log.Error().Err(err).Msg("Error iterating projects") | ||
| } | ||
|
|
||
| return states | ||
| } | ||
|
|
||
| // checkTerraformState checks if a project has a Terraform state | ||
| func checkTerraformState(gitlabUrl string, token string, projectID int) bool { | ||
| url := fmt.Sprintf("%s/api/v4/projects/%d/terraform/state/default", gitlabUrl, projectID) | ||
|
|
||
| req, err := http.NewRequest("GET", url, nil) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| req.Header.Set("PRIVATE-TOKEN", token) | ||
|
|
||
| client := httpclient.GetPipeleekHTTPClient("", nil, nil).StandardClient() | ||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| return false | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| // 200 means state exists, 404 means no state | ||
| return resp.StatusCode == http.StatusOK | ||
| } | ||
|
|
||
| // downloadAndScanStates downloads state files and scans them for secrets | ||
| func downloadAndScanStates(states []terraformState, options TFOptions) { | ||
| var wg sync.WaitGroup | ||
| semaphore := make(chan struct{}, options.Threads) | ||
|
|
||
| for _, state := range states { | ||
| wg.Add(1) | ||
| go func(s terraformState) { | ||
| defer wg.Done() | ||
| semaphore <- struct{}{} | ||
| defer func() { <-semaphore }() | ||
|
|
||
| downloadAndScan(s, options) | ||
| }(state) | ||
| } | ||
|
|
||
| wg.Wait() | ||
| } | ||
|
|
||
| // downloadAndScan downloads a single state file and scans it | ||
| func downloadAndScan(state terraformState, options TFOptions) { | ||
| // Download state file | ||
| url := fmt.Sprintf("%s/api/v4/projects/%d/terraform/state/%s", options.GitlabUrl, state.ProjectID, state.Name) | ||
|
|
||
| req, err := http.NewRequest("GET", url, nil) | ||
| if err != nil { | ||
| log.Error().Err(err).Str("project", state.Project.PathWithNamespace).Str("state", state.Name).Msg("Failed to create request") | ||
| return | ||
| } | ||
| req.Header.Set("PRIVATE-TOKEN", options.GitlabApiToken) | ||
|
|
||
| client := httpclient.GetPipeleekHTTPClient("", nil, nil).StandardClient() | ||
| resp, err := client.Do(req) | ||
| if err != nil { | ||
| log.Error().Err(err).Str("project", state.Project.PathWithNamespace).Str("state", state.Name).Msg("Failed to download Terraform state") | ||
| return | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| log.Error().Int("status", resp.StatusCode).Str("project", state.Project.PathWithNamespace).Str("state", state.Name).Msg("Failed to download Terraform state") | ||
| return | ||
| } | ||
|
|
||
| // Read state data | ||
| stateData, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| log.Error().Err(err).Str("project", state.Project.PathWithNamespace).Str("state", state.Name).Msg("Failed to read state data") | ||
| return | ||
| } | ||
|
|
||
| // Save to file | ||
| filename := fmt.Sprintf("%d_%s.tfstate", state.ProjectID, sanitizeFilename(state.Name)) | ||
| filePath := filepath.Join(options.OutputDir, filename) | ||
|
|
||
| if err := os.WriteFile(filePath, stateData, 0o644); err != nil { | ||
| log.Error().Err(err).Str("file", filePath).Msg("Failed to write state file") | ||
| return | ||
| } | ||
|
|
||
| log.Info().Str("project", state.Project.PathWithNamespace).Str("state", state.Name).Str("file", filePath).Msg("Downloaded Terraform state") | ||
|
|
||
| // Scan the file for secrets | ||
| scanStateFile(stateData, filePath, state, options) | ||
| } | ||
|
|
||
| // scanStateFile scans a Terraform state file for secrets | ||
| func scanStateFile(content []byte, filePath string, state terraformState, options TFOptions) { | ||
| log.Debug().Str("file", filePath).Msg("Scanning Terraform state for secrets") | ||
|
|
||
| findings, err := scanner.DetectHits(content, options.Threads, options.TruffleHogVerification, options.HitTimeout) | ||
| if err != nil { | ||
| log.Debug().Err(err).Str("file", filePath).Msg("Failed detecting secrets") | ||
| return | ||
| } | ||
|
|
||
| if len(findings) > 0 { | ||
| log.Warn().Int("findings", len(findings)).Str("project", state.Project.PathWithNamespace).Str("state", state.Name).Str("file", filePath).Msg("Secrets found in Terraform state") | ||
|
|
||
| for _, finding := range findings { | ||
| logging.Hit(). | ||
| Str("type", "terraform-state"). | ||
| Str("project", state.Project.PathWithNamespace). | ||
| Str("url", state.Project.WebURL). | ||
| Str("state", state.Name). | ||
| Str("file", filePath). | ||
| Str("ruleName", finding.Pattern.Pattern.Name). | ||
| Str("confidence", finding.Pattern.Pattern.Confidence). | ||
| Str("value", finding.Text). | ||
| Msg("SECRET") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // sanitizeFilename removes invalid characters from filenames | ||
| func sanitizeFilename(name string) string { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if really needed use the golangs stdlib functions |
||
| // Replace common invalid characters | ||
| replacements := map[rune]rune{ | ||
| '/': '_', | ||
| '\\': '_', | ||
| ':': '_', | ||
| '*': '_', | ||
| '?': '_', | ||
| '"': '_', | ||
| '<': '_', | ||
| '>': '_', | ||
| '|': '_', | ||
| } | ||
|
|
||
| runes := []rune(name) | ||
| for i, r := range runes { | ||
| if replacement, ok := replacements[r]; ok { | ||
| runes[i] = replacement | ||
| } | ||
| } | ||
|
|
||
| return string(runes) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The variable
maxArtifactSizeis declared but never used in this command. It's only passed toAddCommonScanFlagsbut the value is never read or utilized later in thetfRunfunction. This variable should be removed if it's not needed for the Terraform state scanning functionality.