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
97 changes: 76 additions & 21 deletions pkg/scanner/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type MitigationAnalysis struct {
PermissionGateJob bool // Upstream job verifies collaborator permissions via API
BuildBeforeCheckout bool // Binary compiled from base branch BEFORE PR code checkout
NoCredentialExec bool // Executing job has empty permissions (no token) AND references no secrets
ReadOnlyToken bool // Executing job has a read-only token AND references no secrets
AuthorAssociationGate bool // Job if: restricts execution by author_association (trusted commenters/contributors)
Details []string
}
Expand Down Expand Up @@ -224,6 +225,13 @@ func CheckPwnRequest(wf *Workflow) []Finding {
severity = SeverityInfo
confidence = ConfidencePatternOnly
mitigated = true
} else if mitigation.ReadOnlyToken && !mitigated {
// Read-only token + no secrets: fork code runs but the token can't
// write and there's nothing to steal. Cap at low.
if severityRank(severity) < severityRank(SeverityLow) {
severity = SeverityLow
}
mitigated = true
}

// Permission gate job: upstream job verifies collaborator access — internal threat only
Expand Down Expand Up @@ -1093,23 +1101,57 @@ func analyzeMitigations(wf *Workflow, job Job, checkoutIdx int, postCheckoutStep
if jobHasNoToken(wf, job) && !postCheckoutAccessesSecrets(postCheckoutSteps) {
m.NoCredentialExec = true
m.Details = append(m.Details, "executing job has empty permissions (no GITHUB_TOKEN) and references no secrets")
} else if jobHasReadOnlyToken(wf, job) && !postCheckoutAccessesSecrets(postCheckoutSteps) {
// Read-only token (`read-all` or all-`read` scopes) + no secrets: fork
// code runs but the token can't push/comment and there is nothing to
// exfiltrate. Not full suppression (a token + runner still exist, and a
// private-repo read token has some value), but a strong downgrade.
m.ReadOnlyToken = true
m.Details = append(m.Details, "executing job has a read-only token and references no secrets")
}

return m
}

// jobHasNoToken reports whether the effective permissions of the job (its own
// block, or the workflow-level block if the job doesn't set one) explicitly
// grant no token access — i.e. `permissions: {}`. This removes the GITHUB_TOKEN
// entirely, so fork code cannot abuse it.
func jobHasNoToken(wf *Workflow, job Job) bool {
perm := job.Permissions
if !perm.Set {
perm = wf.Permissions
// jobEffectivePermissions returns the job's own permissions block, or the
// workflow-level block if the job doesn't set one.
func jobEffectivePermissions(wf *Workflow, job Job) PermissionsConfig {
if job.Permissions.Set {
return job.Permissions
}
return wf.Permissions
}

// jobHasNoToken reports whether the job's effective permissions are `{}` — no
// token at all.
func jobHasNoToken(wf *Workflow, job Job) bool {
perm := jobEffectivePermissions(wf, job)
return perm.Set && !perm.WriteAll && !perm.ReadAll && len(perm.Scopes) == 0
}

// jobHasReadOnlyToken reports whether the job's effective GITHUB_TOKEN is
// read-only: `read-all`, or an explicit scope block where every scope is
// `read`/`none` (and at least one scope is set). Excludes the empty `{}` case
// (that's jobHasNoToken).
func jobHasReadOnlyToken(wf *Workflow, job Job) bool {
perm := jobEffectivePermissions(wf, job)
if !perm.Set || perm.WriteAll {
return false
}
if perm.ReadAll {
return true
}
if len(perm.Scopes) == 0 {
return false // permissions: {} — handled by jobHasNoToken
}
for _, v := range perm.Scopes {
if v != "read" && v != "none" {
return false
}
}
return true
}

// detectBuildBeforeCheckout detects the defense-in-depth pattern where a binary
// is compiled from the trusted base branch before the PR's code is checked out.
// The attacker cannot control the binary's code because it was built from base.
Expand Down Expand Up @@ -1145,20 +1187,26 @@ func detectBuildBeforeCheckout(steps []Step, prCheckoutIdx int) bool {
}

// downgradeBy reduces severity by N levels on the severity ladder.
func downgradeBy(severity string, levels int) string {
order := []string{SeverityCritical, SeverityHigh, SeverityMedium, SeverityLow, SeverityInfo}
idx := 0
for i, s := range order {
var severityOrder = []string{SeverityCritical, SeverityHigh, SeverityMedium, SeverityLow, SeverityInfo}

// severityRank returns the index of a severity in severityOrder (0 = critical,
// higher = less severe). Unknown severities rank as most severe (0).
func severityRank(severity string) int {
for i, s := range severityOrder {
if s == severity {
idx = i
break
return i
}
}
return 0
}

func downgradeBy(severity string, levels int) string {
idx := severityRank(severity)
idx += levels
if idx >= len(order) {
idx = len(order) - 1
if idx >= len(severityOrder) {
idx = len(severityOrder) - 1
}
return order[idx]
return severityOrder[idx]
}

// containsOnly checks if a slice contains only the specified allowed values.
Expand Down Expand Up @@ -2640,11 +2688,18 @@ func CheckCurlPipeBash(wf *Workflow) []Finding {
// action definition comes from trusted code, not the fork.
func localActionUnderTrustedCheckout(job Job, uses string) bool {
p := strings.TrimPrefix(uses, "./")
seg := p
if i := strings.Index(p, "/"); i != -1 {
seg = p[:i]
// A parent-relative path (`./../<dir>/...`) points at a sibling checkout dir;
// take the first non-".."/"." segment as the checkout path (e.g.
// `./../cilium-base-branch/.github/actions/cosign` -> `cilium-base-branch`).
seg := ""
for _, s := range strings.Split(p, "/") {
if s == "" || s == "." || s == ".." {
continue
}
seg = s
break
}
if seg == "" || seg == "." {
if seg == "" {
return false
}
for _, step := range job.Steps {
Expand Down
10 changes: 5 additions & 5 deletions pkg/scanner/rules_fixtures_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ func TestCheckPwnRequest_NpmInstall(t *testing.T) {
t.Fatalf("expected 1 finding, got %d", len(findings))
}
f := findings[0]
if f.Severity != SeverityCritical {
t.Errorf("expected critical severity, got %s", f.Severity)
}
if f.Confidence != ConfidenceConfirmed {
t.Errorf("expected confirmed confidence, got %s", f.Confidence)
// Fixture has `permissions: contents: read` (read-only) and no secrets, so
// the read-only-token mitigation caps this at low: fork code runs but the
// token can't write and there's nothing to exfiltrate.
if f.Severity != SeverityLow {
t.Errorf("expected low severity (read-only token, no secrets), got %s", f.Severity)
}
}

Expand Down
26 changes: 26 additions & 0 deletions pkg/scanner/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -972,6 +972,32 @@ func TestIsTrustedRef(t *testing.T) {
}
}

func TestJobTokenClassification(t *testing.T) {
wf := &Workflow{}
mk := func(p PermissionsConfig) Job { return Job{Permissions: p} }

// read-all → read-only, not no-token
ra := mk(PermissionsConfig{Set: true, ReadAll: true})
if !jobHasReadOnlyToken(wf, ra) || jobHasNoToken(wf, ra) {
t.Error("read-all should be read-only, not no-token")
}
// {} → no-token
empty := mk(PermissionsConfig{Set: true})
if !jobHasNoToken(wf, empty) || jobHasReadOnlyToken(wf, empty) {
t.Error("empty permissions should be no-token")
}
// contents: read → read-only
ro := mk(PermissionsConfig{Set: true, Scopes: map[string]string{"contents": "read"}})
if !jobHasReadOnlyToken(wf, ro) {
t.Error("all-read scopes should be read-only")
}
// contents: write → neither
rw := mk(PermissionsConfig{Set: true, Scopes: map[string]string{"contents": "write"}})
if jobHasReadOnlyToken(wf, rw) || jobHasNoToken(wf, rw) {
t.Error("a write scope should be neither read-only nor no-token")
}
}

// --- AllRules completeness ---

func TestAllRules_IncludesNewRules(t *testing.T) {
Expand Down