From 43397b1205f6d62e3ef2544ff4335961844349f6 Mon Sep 17 00:00:00 2001 From: theworker02 Date: Fri, 14 Aug 2026 03:03:16 -0400 Subject: [PATCH] feat: detect GitHub Actions privilege risks for v1.3.0 Co-authored-by: Cursor --- CHANGELOG.md | 12 ++++++ README.md | 4 +- docs/rules.md | 12 ++++++ internal/scanner/rules.go | 54 ++++++++++++++++++++++++ internal/scanner/scanner_test.go | 70 ++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4b120..a73b925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to BeforeRun are documented here. +## v1.3.0 — 2026-08-14 + +### Added + +- `BR012` GitHub Actions privilege detection for `pull_request_target`, `workflow_run`, and `permissions: write-all`. +- Critical severity when a `pull_request_target` workflow also checks out or interpolates untrusted pull-request content. + +### Compatibility + +- Existing scan, compare, CLI, output, rule, and ignore behavior is unchanged. +- The release is fully additive and remains source-compatible with v1.2.0. + ## v1.2.0 — 2026-08-11 ### Added diff --git a/README.md b/README.md index 7ae216c..ec5a92b 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ BeforeRun never executes repository code, installs dependencies, or uploads scan - executable or dynamic binary artifacts; - symbolic links escaping the repository root; - suspicious local or relative submodules; -- bidirectional Unicode source deception. +- bidirectional Unicode source deception; +- GitHub Actions `pull_request_target` / `workflow_run` privilege risks and `permissions: write-all`. ## Install the CLI @@ -156,6 +157,7 @@ The public package exports `Scan`, `Options`, `Summary`, `Finding`, severity con | `BR009` | Executable and dynamic binary artifacts | Medium–High | | `BR010` | Executable script files | Low | | `BR011` | Symlinks escaping the repository root | High | +| `BR012` | GitHub Actions privilege and untrusted-content risks | High–Critical | See [docs/rules.md](docs/rules.md) for rationale and remediation guidance. diff --git a/docs/rules.md b/docs/rules.md index e8f5e84..2234b9d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -67,3 +67,15 @@ Reports scripts with executable mode. This is low severity because it is common Detects symbolic links whose resolved target is outside the scan root. **Review:** verify the external target is intentional, stable, and safe; otherwise remove the link. + +## BR012 — GitHub Actions privilege risks + +Detects GitHub Actions workflows under `.github/workflows/` that: + +- use `pull_request_target` (high), especially when they also check out or interpolate untrusted pull-request content (critical); +- use `workflow_run`, which can inherit secrets after an untrusted workflow finishes (high); +- grant `permissions: write-all` (high). + +These patterns can let untrusted pull-request content run with repository secrets or write access. + +**Review:** prefer `pull_request` for untrusted code, pin the minimum permission scopes, and never interpolate PR-controlled values into `run:` scripts in privileged jobs. diff --git a/internal/scanner/rules.go b/internal/scanner/rules.go index a7ed93b..dfa6d94 100644 --- a/internal/scanner/rules.go +++ b/internal/scanner/rules.go @@ -31,8 +31,12 @@ var textRules = []rule{ ruleEmbeddedSecrets, ruleBidirectionalControls, ruleGitmodules, + ruleGitHubActions, } +var githubWriteAll = regexp.MustCompile(`(?i)permissions\s*:\s*write-all`) +var githubUntrustedRef = regexp.MustCompile(`(?i)github\.(event\.pull_request|head_ref|event\.issue_comment|event\.comment)`) + var pipeToShell = regexp.MustCompile(`(?i)(curl|wget)[^\n|]{0,300}\|\s*(sh|bash|zsh|fish|powershell|pwsh)\b`) var powershellExecution = regexp.MustCompile(`(?i)(invoke-expression|\biex\b|downloadstring\s*\(|frombase64string\s*\(|-(?:enc|encodedcommand)\b)`) var likelySecret = regexp.MustCompile(`(?i)(api[_-]?key|access[_-]?token|secret|password|passwd|private[_-]?key)\s*[:=]\s*["']?[^\s"']{8,}`) @@ -220,6 +224,56 @@ func ruleGitmodules(fc fileContext) []model.Finding { return nil } +func ruleGitHubActions(fc fileContext) []model.Finding { + rel := filepath.ToSlash(fc.RelPath) + if !strings.HasPrefix(rel, ".github/workflows/") { + return nil + } + ext := strings.ToLower(filepath.Ext(rel)) + if ext != ".yml" && ext != ".yaml" { + return nil + } + + lower := strings.ToLower(string(fc.Data)) + var findings []model.Finding + + if strings.Contains(lower, "pull_request_target") { + severity := model.SeverityHigh + message := "workflow uses pull_request_target, which runs with base-repository privileges on untrusted pull requests" + evidence := "on: pull_request_target" + if githubUntrustedRef.Find(fc.Data) != nil { + severity = model.SeverityCritical + message = "pull_request_target workflow checks out or interpolates untrusted pull-request content" + evidence = "pull_request_target with github.event.pull_request / github.head_ref" + } + findings = append(findings, model.NewFinding( + "BR012", severity, fc.RelPath, lineOf(fc.Data, []byte("pull_request_target")), + message, evidence, + "Prefer pull_request for untrusted code. If pull_request_target is required, never check out the PR head or interpolate PR-controlled values into run scripts.", + )) + } + + if strings.Contains(lower, "workflow_run") { + findings = append(findings, model.NewFinding( + "BR012", model.SeverityHigh, fc.RelPath, lineOf(fc.Data, []byte("workflow_run")), + "workflow_run can inherit secrets after an untrusted workflow finishes", + "on: workflow_run", + "Treat workflow_run artifacts as untrusted. Do not check out the triggering PR or expand its inputs in privileged jobs.", + )) + } + + if loc := githubWriteAll.Find(fc.Data); loc != nil { + findings = append(findings, model.NewFinding( + "BR012", model.SeverityHigh, fc.RelPath, lineOf(fc.Data, loc), + "workflow grants permissions: write-all", + "permissions: write-all", + "Replace write-all with the minimum required permission scopes.", + )) + } + + return findings +} + func isExecutionSurface(path string) bool { base := strings.ToLower(filepath.Base(path)) if base == "makefile" || base == "dockerfile" || base == "justfile" || base == "taskfile.yml" || base == "taskfile.yaml" || base == "package.json" { diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 16d3c0a..b161776 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -65,6 +65,76 @@ func TestDetectsEscapingSymlink(t *testing.T) { } } +func TestDetectsGitHubActionsPrivilegeRisks(t *testing.T) { + root := t.TempDir() + workflowDir := filepath.Join(root, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatal(err) + } + content := ` +on: pull_request_target +permissions: write-all +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} +` + if err := os.WriteFile(filepath.Join(workflowDir, "untrusted.yml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + summary, err := Scan(root, Options{Threshold: model.SeverityHigh}) + if err != nil { + t.Fatal(err) + } + if !summary.ThresholdMet { + t.Fatal("expected threshold to be met") + } + var messages []string + for _, finding := range summary.Findings { + if finding.Rule == "BR012" { + messages = append(messages, finding.Message) + } + } + if len(messages) < 2 { + t.Fatalf("expected multiple BR012 findings, got %#v", summary.Findings) + } + foundCriticalPR := false + foundWriteAll := false + for _, finding := range summary.Findings { + if finding.Rule != "BR012" { + continue + } + if finding.Severity == model.SeverityCritical { + foundCriticalPR = true + } + if finding.Severity == model.SeverityHigh && finding.Path == ".github/workflows/untrusted.yml" { + foundWriteAll = true + } + } + if !foundCriticalPR || !foundWriteAll { + t.Fatalf("expected critical pull_request_target and high write-all findings, got %#v", summary.Findings) + } +} + +func TestIgnoresNonWorkflowYAMLForBR012(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "deploy.yml"), []byte("on: pull_request_target\npermissions: write-all\n"), 0o644); err != nil { + t.Fatal(err) + } + + summary, err := Scan(root, Options{Threshold: model.SeverityLow}) + if err != nil { + t.Fatal(err) + } + if hasRule(summary.Findings, "BR012") { + t.Fatalf("unexpected BR012 outside workflows: %#v", summary.Findings) + } +} + func TestDoesNotFlagRegexDefinitionAsSecret(t *testing.T) { root := t.TempDir() content := "package x\nvar likelySecret = regexp.MustCompile(`(?i)secret\\s*=`)\n"