Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 54 additions & 0 deletions internal/scanner/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,}`)
Expand Down Expand Up @@ -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" {
Expand Down
70 changes: 70 additions & 0 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down