Skip to content

Commit 22c30ae

Browse files
north-echoclaude
andcommitted
feat: multi-platform CI/CD scanning — Jenkins, Tekton, CircleCI + expanded GitLab/Azure
New platforms: - Jenkins: declarative Jenkinsfile parser + 4 rules (JK-001 through JK-009) - Tekton: Pipeline/Task YAML parser + 4 rules (TK-001 through TK-009) - CircleCI: config.yml parser + 4 rules (CC-001 through CC-009) Expanded existing platforms: - GitLab CI: 5 new rules (GL-004 through GL-010) — broad permissions, secrets in logs, fork MR exec, OIDC, cache poisoning - Azure Pipelines: 5 new rules (AZ-004 through AZ-010) — matching GitLab expansion Remote API clients: - internal/gitlab/client.go: GitLab API client with group enumeration, rate limiting - internal/azure/client.go: Azure DevOps API client with project enumeration - fluxgate remote --platform gitlab|azure support Scanner integration: - ScanDirectory now detects Jenkinsfile, .tekton/, .circleci/config.yml - filterFindings helper deduplicates severity/rule filtering across platforms Total rules: 41 (was 19). Platforms: 6 (was 3). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aaff4cf commit 22c30ae

37 files changed

Lines changed: 3632 additions & 20 deletions

cmd/fluxgate/main.go

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ import (
1414
"strings"
1515
"time"
1616

17+
azureclient "github.com/north-echo/fluxgate/internal/azure"
1718
"github.com/north-echo/fluxgate/internal/dashboard"
1819
"github.com/north-echo/fluxgate/internal/diff"
1920
"github.com/north-echo/fluxgate/internal/export"
2021
ghclient "github.com/north-echo/fluxgate/internal/github"
22+
gitlabclient "github.com/north-echo/fluxgate/internal/gitlab"
2123
"github.com/north-echo/fluxgate/internal/merge"
2224
"github.com/north-echo/fluxgate/internal/report"
2325
"github.com/north-echo/fluxgate/internal/scanner"
@@ -136,42 +138,80 @@ func newRemoteCmd() *cobra.Command {
136138
severities string
137139
rules string
138140
token string
141+
platform string
142+
baseURL string
139143
)
140144

141145
cmd := &cobra.Command{
142146
Use: "remote [owner/repo]",
143-
Short: "Scan a remote GitHub repository",
144-
Long: "Fetch and scan GitHub Actions workflows from a remote repository via the GitHub API.",
145-
Args: cobra.ExactArgs(1),
146-
RunE: func(cmd *cobra.Command, args []string) error {
147-
parts := strings.SplitN(args[0], "/", 2)
148-
if len(parts) != 2 {
149-
return fmt.Errorf("invalid repo format: use owner/repo")
150-
}
151-
owner, repo := parts[0], parts[1]
147+
Short: "Scan a remote repository",
148+
Long: `Fetch and scan CI/CD pipelines from a remote repository.
152149
150+
Platforms:
151+
github (default) — GitHub Actions via GitHub API
152+
gitlab — GitLab CI via GitLab API (use --url for self-hosted)
153+
azure — Azure Pipelines via Azure DevOps API (use --url for org URL)`,
154+
Args: cobra.ExactArgs(1),
155+
RunE: func(cmd *cobra.Command, args []string) error {
153156
if token == "" {
154157
token = os.Getenv("GITHUB_TOKEN")
155158
}
156159

157-
client := ghclient.NewClient(token)
160+
opts := parseScanOpts(severities, rules)
158161
ctx := context.Background()
159162

160-
opts := parseScanOpts(severities, rules)
161-
result, err := client.ScanRemote(ctx, owner, repo, opts)
162-
if err != nil {
163-
return fmt.Errorf("remote scan failed: %w", err)
164-
}
163+
switch platform {
164+
case "github", "":
165+
parts := strings.SplitN(args[0], "/", 2)
166+
if len(parts) != 2 {
167+
return fmt.Errorf("invalid repo format: use owner/repo")
168+
}
169+
client := ghclient.NewClient(token)
170+
result, err := client.ScanRemote(ctx, parts[0], parts[1], opts)
171+
if err != nil {
172+
return fmt.Errorf("remote scan failed: %w", err)
173+
}
174+
return outputResult(result, format, output)
165175

166-
return outputResult(result, format, output)
176+
case "gitlab":
177+
if token == "" {
178+
token = os.Getenv("GITLAB_TOKEN")
179+
}
180+
client := gitlabclient.NewClient(baseURL, token)
181+
result, err := client.ScanRemote(ctx, args[0], opts)
182+
if err != nil {
183+
return fmt.Errorf("gitlab scan failed: %w", err)
184+
}
185+
return outputResult(result, format, output)
186+
187+
case "azure":
188+
if token == "" {
189+
token = os.Getenv("AZURE_DEVOPS_TOKEN")
190+
}
191+
parts := strings.SplitN(args[0], "/", 2)
192+
if len(parts) != 2 {
193+
return fmt.Errorf("invalid format: use project/repo")
194+
}
195+
client := azureclient.NewClient(baseURL, token)
196+
result, err := client.ScanRemote(ctx, parts[0], parts[1], opts)
197+
if err != nil {
198+
return fmt.Errorf("azure scan failed: %w", err)
199+
}
200+
return outputResult(result, format, output)
201+
202+
default:
203+
return fmt.Errorf("unknown platform %q (use github, gitlab, or azure)", platform)
204+
}
167205
},
168206
}
169207

170208
cmd.Flags().StringVarP(&format, "format", "f", "table", "Output format: table, json, sarif")
171209
cmd.Flags().StringVarP(&output, "output", "o", "", "Output file (default: stdout)")
172-
cmd.Flags().StringVar(&severities, "severity", "", "Filter by severity (comma-separated: critical,high,medium,low)")
173-
cmd.Flags().StringVar(&rules, "rules", "", "Filter by rule ID (comma-separated: FG-001,FG-002)")
174-
cmd.Flags().StringVarP(&token, "token", "t", "", "GitHub token (default: $GITHUB_TOKEN)")
210+
cmd.Flags().StringVar(&severities, "severity", "", "Filter by severity (comma-separated)")
211+
cmd.Flags().StringVar(&rules, "rules", "", "Filter by rule ID (comma-separated)")
212+
cmd.Flags().StringVarP(&token, "token", "t", "", "API token (default: $GITHUB_TOKEN, $GITLAB_TOKEN, or $AZURE_DEVOPS_TOKEN)")
213+
cmd.Flags().StringVar(&platform, "platform", "github", "Platform: github, gitlab, azure")
214+
cmd.Flags().StringVar(&baseURL, "url", "", "Base URL for self-hosted instances (e.g., https://gitlab.example.com)")
175215

176216
return cmd
177217
}

internal/azure/client.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package azure
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"time"
12+
13+
"github.com/north-echo/fluxgate/internal/cicd"
14+
"github.com/north-echo/fluxgate/internal/scanner"
15+
)
16+
17+
// Client wraps the Azure DevOps API for fetching pipeline files.
18+
type Client struct {
19+
orgURL string
20+
token string
21+
httpClient *http.Client
22+
}
23+
24+
// RepoInfo represents basic Azure DevOps repository metadata.
25+
type RepoInfo struct {
26+
ID string `json:"id"`
27+
Name string `json:"name"`
28+
DefaultBranch string `json:"defaultBranch"`
29+
Project string `json:"-"` // populated after fetch
30+
}
31+
32+
// repoListResponse is the API response for listing repositories.
33+
type repoListResponse struct {
34+
Value []repoEntry `json:"value"`
35+
Count int `json:"count"`
36+
}
37+
38+
type repoEntry struct {
39+
ID string `json:"id"`
40+
Name string `json:"name"`
41+
DefaultBranch string `json:"defaultBranch"`
42+
Project repoProject `json:"project"`
43+
}
44+
45+
type repoProject struct {
46+
Name string `json:"name"`
47+
}
48+
49+
// NewClient creates an Azure DevOps API client.
50+
// orgURL should be like "https://dev.azure.com/myorg".
51+
func NewClient(orgURL, token string) *Client {
52+
return &Client{
53+
orgURL: orgURL,
54+
token: token,
55+
httpClient: &http.Client{
56+
Timeout: 30 * time.Second,
57+
},
58+
}
59+
}
60+
61+
// FetchPipelineFile fetches the azure-pipelines.yml file from a repository.
62+
func (c *Client) FetchPipelineFile(ctx context.Context, project, repoID string) ([]byte, error) {
63+
endpoint := fmt.Sprintf("/%s/_apis/git/repositories/%s/items",
64+
url.PathEscape(project), url.PathEscape(repoID))
65+
66+
params := url.Values{
67+
"path": {"azure-pipelines.yml"},
68+
"api-version": {"7.0"},
69+
}
70+
71+
body, err := c.doGet(ctx, endpoint, params)
72+
if err != nil {
73+
return nil, fmt.Errorf("fetching azure-pipelines.yml for %s/%s: %w", project, repoID, err)
74+
}
75+
return body, nil
76+
}
77+
78+
// ListProjectRepos lists all Git repositories in an Azure DevOps project.
79+
func (c *Client) ListProjectRepos(ctx context.Context, project string) ([]RepoInfo, error) {
80+
endpoint := fmt.Sprintf("/%s/_apis/git/repositories", url.PathEscape(project))
81+
params := url.Values{
82+
"api-version": {"7.0"},
83+
}
84+
85+
body, err := c.doGet(ctx, endpoint, params)
86+
if err != nil {
87+
return nil, fmt.Errorf("listing repos for project %s: %w", project, err)
88+
}
89+
90+
var resp repoListResponse
91+
if err := json.Unmarshal(body, &resp); err != nil {
92+
return nil, fmt.Errorf("decoding repos response: %w", err)
93+
}
94+
95+
repos := make([]RepoInfo, len(resp.Value))
96+
for i, r := range resp.Value {
97+
repos[i] = RepoInfo{
98+
ID: r.ID,
99+
Name: r.Name,
100+
DefaultBranch: r.DefaultBranch,
101+
Project: r.Project.Name,
102+
}
103+
}
104+
105+
return repos, nil
106+
}
107+
108+
// ScanRemote fetches, parses, and scans a repository's azure-pipelines.yml file.
109+
func (c *Client) ScanRemote(ctx context.Context, project, repoID string, opts scanner.ScanOptions) (*scanner.ScanResult, error) {
110+
data, err := c.FetchPipelineFile(ctx, project, repoID)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
path := fmt.Sprintf("%s/%s/azure-pipelines.yml", project, repoID)
116+
pipeline, err := cicd.ParseAzurePipeline(data, path)
117+
if err != nil {
118+
return nil, fmt.Errorf("parsing pipeline for %s/%s: %w", project, repoID, err)
119+
}
120+
121+
azFindings := cicd.ScanAzurePipeline(pipeline)
122+
123+
result := &scanner.ScanResult{
124+
Path: fmt.Sprintf("%s/%s", project, repoID),
125+
Workflows: 1,
126+
}
127+
128+
for _, azf := range azFindings {
129+
f := scanner.Finding{
130+
RuleID: azf.RuleID,
131+
Severity: azf.Severity,
132+
File: azf.File,
133+
Line: azf.Line,
134+
Message: azf.Message,
135+
Details: azf.Details,
136+
}
137+
result.Findings = append(result.Findings, f)
138+
}
139+
140+
// Apply severity filter
141+
if len(opts.Severities) > 0 {
142+
sevSet := make(map[string]bool)
143+
for _, s := range opts.Severities {
144+
sevSet[s] = true
145+
}
146+
var filtered []scanner.Finding
147+
for _, f := range result.Findings {
148+
if sevSet[f.Severity] {
149+
filtered = append(filtered, f)
150+
}
151+
}
152+
result.Findings = filtered
153+
}
154+
155+
// Apply rule filter
156+
if len(opts.Rules) > 0 {
157+
ruleSet := make(map[string]bool)
158+
for _, r := range opts.Rules {
159+
ruleSet[r] = true
160+
}
161+
var filtered []scanner.Finding
162+
for _, f := range result.Findings {
163+
if ruleSet[f.RuleID] {
164+
filtered = append(filtered, f)
165+
}
166+
}
167+
result.Findings = filtered
168+
}
169+
170+
return result, nil
171+
}
172+
173+
// doGet performs an authenticated GET request with retry logic.
174+
// Azure DevOps uses Basic auth with empty username and PAT as password.
175+
func (c *Client) doGet(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
176+
u := c.orgURL + endpoint
177+
if params != nil {
178+
u += "?" + params.Encode()
179+
}
180+
181+
const maxRetries = 5
182+
for attempt := 0; attempt <= maxRetries; attempt++ {
183+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
184+
if err != nil {
185+
return nil, err
186+
}
187+
188+
// Azure DevOps Basic auth: empty username, PAT as password
189+
if c.token != "" {
190+
auth := base64.StdEncoding.EncodeToString([]byte(":" + c.token))
191+
req.Header.Set("Authorization", "Basic "+auth)
192+
}
193+
194+
resp, err := c.httpClient.Do(req)
195+
if err != nil {
196+
return nil, err
197+
}
198+
199+
body, err := io.ReadAll(resp.Body)
200+
resp.Body.Close()
201+
202+
if resp.StatusCode == http.StatusOK {
203+
return body, err
204+
}
205+
206+
if resp.StatusCode == http.StatusNotFound {
207+
return nil, fmt.Errorf("not found: %s (HTTP %d)", endpoint, resp.StatusCode)
208+
}
209+
210+
// Retry on rate limits and transient errors
211+
if attempt < maxRetries && (resp.StatusCode == http.StatusTooManyRequests ||
212+
resp.StatusCode == http.StatusServiceUnavailable ||
213+
resp.StatusCode == http.StatusBadGateway) {
214+
215+
wait := time.Duration(1<<uint(attempt)) * time.Second
216+
217+
// Check Retry-After header
218+
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
219+
if secs, err := time.ParseDuration(retryAfter + "s"); err == nil {
220+
wait = secs
221+
}
222+
}
223+
224+
select {
225+
case <-ctx.Done():
226+
return nil, ctx.Err()
227+
case <-time.After(wait):
228+
continue
229+
}
230+
}
231+
232+
return nil, fmt.Errorf("Azure DevOps API error: %s (HTTP %d): %s", endpoint, resp.StatusCode, string(body))
233+
}
234+
235+
return nil, fmt.Errorf("exceeded maximum retries for %s", endpoint)
236+
}

internal/cicd/azure.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,11 +419,13 @@ func convertAzureStep(raw *rawAzureStep) []PipelineStep {
419419
})
420420
}
421421
if raw.Task != "" {
422+
// Merge Env and Inputs for task steps so rules can inspect both
423+
taskEnv := mergeStringMaps(raw.Env, raw.Inputs)
422424
steps = append(steps, PipelineStep{
423425
Name: raw.DisplayName,
424426
Type: StepAction,
425427
Command: raw.Task,
426-
Env: raw.Env,
428+
Env: taskEnv,
427429
})
428430
}
429431
if raw.Template != "" {
@@ -495,3 +497,19 @@ func extractAzureSecretRefs(node *yaml.Node) []string {
495497
}
496498
return secrets
497499
}
500+
501+
// mergeStringMaps merges two string maps, with b values overriding a values.
502+
// Returns nil if both inputs are nil/empty.
503+
func mergeStringMaps(a, b map[string]string) map[string]string {
504+
if len(a) == 0 && len(b) == 0 {
505+
return nil
506+
}
507+
result := make(map[string]string)
508+
for k, v := range a {
509+
result[k] = v
510+
}
511+
for k, v := range b {
512+
result[k] = v
513+
}
514+
return result
515+
}

0 commit comments

Comments
 (0)