Skip to content

Commit c05aa21

Browse files
north-echoclaude
andcommitted
feat: fluxgate v0.6.0 — bot TOCTOU detection, dispatch injection, attack correlation
FG-011: New rule detects bot actor guard TOCTOU bypass risk on pull_request_target and workflow_run triggers. Bot actor guards (dependabot[bot], renovate[bot]) no longer suppress FG-001 findings to info — capped at high to reflect TOCTOU bypassability. FG-002 extended: workflow_dispatch inputs and workflow_call inputs now detected as injectable expressions (github.event.inputs.*, inputs.*). FG-001+FG-002 correlation: post-scan pass merges co-occurring pwn request and script injection findings into a single enhanced finding referencing the Ultralytics attack pattern. Triage prompts added with BoostSecurity attack taxonomy (pipeline parasitism, transitive action compromise, bot TOCTOU, Shai-Hulud, Ultralytics chain). 21 rules across 3 platforms, 69 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d5b120 commit c05aa21

File tree

11 files changed

+469
-11
lines changed

11 files changed

+469
-11
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,40 @@ go install github.com/north-echo/fluxgate/cmd/fluxgate@latest
2222

2323
## What It Detects
2424

25+
### GitHub Actions (FG-xxx)
26+
2527
| Rule | Severity | Description |
2628
|---------|----------|-------------|
2729
| FG-001 | Critical | Pwn Request: pull_request_target with fork checkout |
28-
| FG-002 | High | Script Injection via expression interpolation |
30+
| FG-002 | High | Script Injection via expression interpolation (PR context, dispatch inputs, reusable workflow inputs) |
2931
| FG-003 | Medium | Tag-based action pinning (mutable references) |
3032
| FG-004 | Medium | Overly broad workflow permissions |
3133
| FG-005 | Low | Secrets exposed in workflow logs |
34+
| FG-006 | Medium | Fork PR code execution via build hooks |
35+
| FG-007 | Medium | Inconsistent GITHUB_TOKEN blanking |
36+
| FG-008 | Critical | OIDC misconfiguration on external triggers |
37+
| FG-009 | High | Self-hosted runner on external triggers |
38+
| FG-010 | High | Cache poisoning via shared cache on PR workflows |
39+
| FG-011 | Medium | Bot actor guard TOCTOU bypass risk |
40+
41+
### GitLab CI (GL-xxx)
42+
43+
| Rule | Severity | Description |
44+
|---------|----------|-------------|
45+
| GL-001 | High | Merge request pipeline with privileged variables |
46+
| GL-002 | High | Script injection via CI predefined variables |
47+
| GL-003 | Medium | Unpinned include templates |
48+
49+
### Azure Pipelines (AZ-xxx)
50+
51+
| Rule | Severity | Description |
52+
|---------|----------|-------------|
53+
| AZ-001 | High | Fork PR builds with secret/variable group exposure |
54+
| AZ-002 | High | Script injection via Azure predefined variables |
55+
| AZ-003 | Medium | Unpinned template extends and repository resources |
56+
| AZ-009 | High | Self-hosted agent pools on PR-triggered pipelines |
57+
58+
**21 rules across 3 CI/CD platforms.**
3259

3360
## Why This Exists
3461

cmd/fluxgate/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
"github.com/spf13/cobra"
1717
)
1818

19-
var version = "0.5.0"
19+
var version = "0.6.0"
2020

2121
func main() {
2222
rootCmd := &cobra.Command{

internal/report/table.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
// WriteTable writes findings as a human-readable table.
1313
func WriteTable(w io.Writer, result *scanner.ScanResult) {
14-
fmt.Fprintln(w, "fluxgate v0.5.0 — CI/CD Pipeline Security Gate")
14+
fmt.Fprintln(w, "fluxgate v0.6.0 — CI/CD Pipeline Security Gate")
1515
fmt.Fprintln(w)
1616
fmt.Fprintf(w, "Scanning: %s\n\n", result.Path)
1717

internal/scanner/rules.go

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ func AllRules() map[string]Rule {
2222
"FG-008": CheckOIDCMisconfiguration,
2323
"FG-009": CheckSelfHostedRunner,
2424
"FG-010": CheckCachePoisoning,
25+
"FG-011": CheckBotActorTOCTOU,
2526
}
2627
}
2728

@@ -37,6 +38,7 @@ var RuleDescriptions = map[string]string{
3738
"FG-008": "OIDC Misconfiguration",
3839
"FG-009": "Self-Hosted Runner",
3940
"FG-010": "Cache Poisoning",
41+
"FG-011": "Bot Actor Guard TOCTOU",
4042
}
4143

4244
// ExecutionAnalysis captures the result of post-checkout step analysis.
@@ -147,10 +149,16 @@ func CheckPwnRequest(wf *Workflow) []Finding {
147149
mitigation := analyzeMitigations(wf, job, checkoutIdx, postCheckoutSteps, checkoutPath)
148150
mitigated := false
149151

150-
if mitigation.ForkGuard || mitigation.ActorGuard {
152+
if mitigation.ForkGuard {
151153
severity = SeverityInfo
152154
confidence = ConfidencePatternOnly
153155
mitigated = true
156+
} else if mitigation.ActorGuard {
157+
// Bot actor guards are bypassable via TOCTOU — cap at high, never suppress
158+
if severity == SeverityCritical {
159+
severity = SeverityHigh
160+
}
161+
mitigated = true
154162
} else if mitigation.LabelGated && mitigation.EnvironmentGated {
155163
severity = downgradeBy(severity, 2)
156164
mitigated = true
@@ -368,6 +376,8 @@ func CheckScriptInjection(wf *Workflow) []Finding {
368376
"github.event.head_commit.message",
369377
"github.head_ref",
370378
"github.event.workflow_run.head_branch",
379+
"github.event.inputs.",
380+
"inputs.",
371381
}
372382

373383
var findings []Finding
@@ -1367,6 +1377,98 @@ func CheckCachePoisoning(wf *Workflow) []Finding {
13671377
return findings
13681378
}
13691379

1380+
// CheckBotActorTOCTOU detects workflows where a bot actor guard (e.g.,
1381+
// if: github.actor == 'dependabot[bot]') protects a fork checkout + execution
1382+
// path that may be bypassable via TOCTOU: an attacker updates their PR commit
1383+
// after the bot triggers the workflow but before the runner resolves the SHA (FG-011).
1384+
func CheckBotActorTOCTOU(wf *Workflow) []Finding {
1385+
if !wf.On.PullRequestTarget && !wf.On.WorkflowRun {
1386+
return nil
1387+
}
1388+
1389+
var findings []Finding
1390+
for jobName, job := range wf.Jobs {
1391+
// Must have a bot actor guard
1392+
isBot := false
1393+
if job.If != "" {
1394+
isBot, _ = containsActorGuard(job.If)
1395+
}
1396+
// Also check needs chain for inherited actor guards
1397+
if !isBot {
1398+
for _, depName := range job.Needs {
1399+
if dep, ok := wf.Jobs[depName]; ok {
1400+
if dep.If != "" {
1401+
isBot, _ = containsActorGuard(dep.If)
1402+
if isBot {
1403+
break
1404+
}
1405+
}
1406+
}
1407+
}
1408+
}
1409+
if !isBot {
1410+
continue
1411+
}
1412+
1413+
// Must have fork checkout
1414+
checkoutIdx := -1
1415+
checkoutPath := ""
1416+
for i, step := range job.Steps {
1417+
if isCheckoutAction(step.Uses) && refPointsToPRHead(step.With["ref"]) {
1418+
checkoutIdx = i
1419+
checkoutPath = step.With["path"]
1420+
break
1421+
}
1422+
}
1423+
if checkoutIdx == -1 {
1424+
continue
1425+
}
1426+
1427+
// Must have post-checkout execution
1428+
postCheckoutSteps := job.Steps[checkoutIdx+1:]
1429+
execResult := analyzePostCheckoutExecution(postCheckoutSteps)
1430+
1431+
// Also check path isolation — if fork code is isolated and not executed, skip
1432+
if checkoutPath != "" {
1433+
hasForkExec := false
1434+
for _, step := range postCheckoutSteps {
1435+
if step.Run != "" && referencesForkPath(step.Run, checkoutPath) {
1436+
hasForkExec = true
1437+
break
1438+
}
1439+
}
1440+
if !hasForkExec && !execResult.Confirmed && !execResult.Likely {
1441+
continue
1442+
}
1443+
} else if !execResult.Confirmed && !execResult.Likely {
1444+
continue
1445+
}
1446+
1447+
trigger := "pull_request_target"
1448+
if !wf.On.PullRequestTarget && wf.On.WorkflowRun {
1449+
trigger = "workflow_run"
1450+
}
1451+
1452+
msg := fmt.Sprintf(
1453+
"Bot Actor Guard TOCTOU: job '%s' on %s has bot actor guard with fork checkout + execution — "+
1454+
"attacker can update PR commit after bot triggers workflow but before runner resolves SHA",
1455+
jobName, trigger)
1456+
1457+
findings = append(findings, Finding{
1458+
RuleID: "FG-011",
1459+
Severity: SeverityMedium,
1460+
File: wf.Path,
1461+
Line: 1,
1462+
Message: msg,
1463+
Details: "Bot-delegated TOCTOU: if: github.actor == 'dependabot[bot]' guards are " +
1464+
"bypassable when an attacker pushes a new commit between the bot trigger event " +
1465+
"and the runner's checkout. The workflow runs the attacker's code with the bot's privileges. " +
1466+
"See BoostSecurity 'Weaponizing Dependabot' research.",
1467+
})
1468+
}
1469+
return findings
1470+
}
1471+
13701472
// describeTrigger returns a human-readable description of the workflow trigger.
13711473
func describeTrigger(wf *Workflow) string {
13721474
if wf.On.PullRequestTarget {

internal/scanner/rules_test.go

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -218,21 +218,52 @@ func TestMixedWorkflow_MultipleFindings(t *testing.T) {
218218
opts := ScanOptions{}
219219
findings := ScanWorkflow(wf, opts)
220220

221-
if len(findings) < 3 {
222-
t.Fatalf("expected at least 3 findings for mixed workflow, got %d", len(findings))
221+
if len(findings) < 2 {
222+
t.Fatalf("expected at least 2 findings for mixed workflow, got %d", len(findings))
223223
}
224224

225225
rulesSeen := map[string]bool{}
226226
for _, f := range findings {
227227
rulesSeen[f.RuleID] = true
228228
}
229-
// Should trigger FG-001 (pwn request), FG-002 (script injection),
230-
// FG-003 (tag pinning), FG-004 (broad permissions)
231-
for _, expected := range []string{"FG-001", "FG-002", "FG-003"} {
229+
// FG-001 and FG-002 are correlated into a single merged FG-001 finding
230+
// FG-003 (tag pinning) should still fire separately
231+
for _, expected := range []string{"FG-001", "FG-003"} {
232232
if !rulesSeen[expected] {
233233
t.Errorf("expected rule %s to fire on mixed workflow", expected)
234234
}
235235
}
236+
// FG-002 should be absorbed into the merged FG-001
237+
if rulesSeen["FG-002"] {
238+
t.Error("expected FG-002 to be merged into FG-001 via correlation, but it still appears separately")
239+
}
240+
}
241+
242+
func TestCorrelation_PwnRequestPlusInjection(t *testing.T) {
243+
wf := loadFixture(t, "mixed-workflow.yaml")
244+
opts := ScanOptions{}
245+
findings := ScanWorkflow(wf, opts)
246+
247+
// Find the correlated FG-001 finding
248+
var merged *Finding
249+
for i, f := range findings {
250+
if f.RuleID == "FG-001" {
251+
merged = &findings[i]
252+
break
253+
}
254+
}
255+
if merged == nil {
256+
t.Fatal("expected merged FG-001 finding")
257+
}
258+
if merged.Severity != SeverityCritical {
259+
t.Errorf("expected critical severity for merged finding, got %s", merged.Severity)
260+
}
261+
if !strings.Contains(merged.Message, "Ultralytics") {
262+
t.Error("expected merged finding to reference Ultralytics pattern")
263+
}
264+
if !strings.Contains(merged.Details, "FG-001+FG-002") {
265+
t.Error("expected merged finding details to mention FG-001+FG-002 correlation")
266+
}
236267
}
237268

238269
// --- FG-001 mitigation tests ---
@@ -356,8 +387,9 @@ func TestCheckPwnRequest_ActorGuardBot(t *testing.T) {
356387
t.Fatalf("expected 1 finding, got %d", len(findings))
357388
}
358389
f := findings[0]
359-
if f.Severity != SeverityInfo {
360-
t.Errorf("expected info severity for bot actor guard, got %s", f.Severity)
390+
// Bot actor guards are TOCTOU-bypassable — cap at high, never suppress to info
391+
if f.Severity != SeverityHigh {
392+
t.Errorf("expected high severity for bot actor guard (TOCTOU cap), got %s", f.Severity)
361393
}
362394
if len(f.Mitigations) == 0 {
363395
t.Error("expected mitigations to be populated")
@@ -628,6 +660,76 @@ func TestCheckPwnRequest_PathAliasExec(t *testing.T) {
628660
}
629661
}
630662

663+
// --- FG-011 Bot Actor Guard TOCTOU tests ---
664+
665+
func TestCheckBotActorTOCTOU_PRT(t *testing.T) {
666+
wf := loadFixture(t, "bot-actor-guard-toctou.yaml")
667+
findings := CheckBotActorTOCTOU(wf)
668+
669+
if len(findings) != 1 {
670+
t.Fatalf("expected 1 FG-011 finding, got %d", len(findings))
671+
}
672+
f := findings[0]
673+
if f.RuleID != "FG-011" {
674+
t.Errorf("expected FG-011, got %s", f.RuleID)
675+
}
676+
if f.Severity != SeverityMedium {
677+
t.Errorf("expected medium severity, got %s", f.Severity)
678+
}
679+
if !strings.Contains(f.Message, "pull_request_target") {
680+
t.Error("expected message to mention pull_request_target trigger")
681+
}
682+
if !strings.Contains(f.Message, "TOCTOU") {
683+
t.Error("expected message to mention TOCTOU")
684+
}
685+
}
686+
687+
func TestCheckBotActorTOCTOU_WorkflowRun(t *testing.T) {
688+
wf := loadFixture(t, "workflow-run-toctou.yaml")
689+
findings := CheckBotActorTOCTOU(wf)
690+
691+
if len(findings) != 1 {
692+
t.Fatalf("expected 1 FG-011 finding for workflow_run, got %d", len(findings))
693+
}
694+
f := findings[0]
695+
if f.RuleID != "FG-011" {
696+
t.Errorf("expected FG-011, got %s", f.RuleID)
697+
}
698+
if !strings.Contains(f.Message, "workflow_run") {
699+
t.Error("expected message to mention workflow_run trigger")
700+
}
701+
}
702+
703+
func TestCheckBotActorTOCTOU_NoBotGuard(t *testing.T) {
704+
// A PRT workflow without actor guard should not fire FG-011
705+
wf := loadFixture(t, "pwn-request.yaml")
706+
findings := CheckBotActorTOCTOU(wf)
707+
708+
if len(findings) != 0 {
709+
t.Fatalf("expected 0 findings for workflow without bot actor guard, got %d", len(findings))
710+
}
711+
}
712+
713+
// --- FG-002 extension: workflow_dispatch/call injection ---
714+
715+
func TestCheckScriptInjection_DispatchInputs(t *testing.T) {
716+
wf := loadFixture(t, "dispatch-injection.yaml")
717+
findings := CheckScriptInjection(wf)
718+
719+
// Should find: inputs.* and github.event.inputs.* (2 pattern matches on the run block)
720+
if len(findings) < 2 {
721+
t.Fatalf("expected at least 2 injection findings for dispatch inputs, got %d", len(findings))
722+
}
723+
for _, f := range findings {
724+
if f.RuleID != "FG-002" {
725+
t.Errorf("expected FG-002, got %s", f.RuleID)
726+
}
727+
if f.Severity != SeverityHigh {
728+
t.Errorf("expected high severity, got %s", f.Severity)
729+
}
730+
}
731+
}
732+
631733
func TestRuleFilter(t *testing.T) {
632734
wf := loadFixture(t, "mixed-workflow.yaml")
633735
opts := ScanOptions{Rules: []string{"FG-002"}}

0 commit comments

Comments
 (0)