Files
2026-07-20 17:06:40 +00:00

204 lines
6.6 KiB
Go

package workflowpolicy
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
var (
secretContextPattern = regexp.MustCompile(`(?i)\bsecrets\b`)
tokenSourcePattern = regexp.MustCompile(`(?i)\bgithub\s*\.\s*token\b|\bGITHUB_TOKEN\b`)
untrustedExpressionPattern = regexp.MustCompile(`\$\{\{[^}]*(?:github\s*\.\s*event\s*\.|github\s*\.\s*head_ref|github\s*\[)[^}]*\}\}`)
)
type checker struct {
repository Repository
violations []Violation
}
func Verify(data []byte, repository Repository) error {
return verify("memory", data, repository)
}
func VerifyFile(path string, repository Repository) error {
root, err := os.OpenRoot(filepath.Dir(path))
if err != nil {
return &ReadError{Path: path, Cause: err}
}
defer root.Close()
data, err := root.ReadFile(filepath.Base(path))
if err != nil {
return &ReadError{Path: path, Cause: err}
}
return verify(path, data, repository)
}
func verify(source string, data []byte, repository Repository) error {
root, err := parseWorkflow(source, data)
if err != nil {
return err
}
policyChecker := checker{repository: repository}
policyChecker.checkWorkflow(root)
if len(policyChecker.violations) > 0 {
return &PolicyError{violations: policyChecker.violations}
}
return nil
}
func (c *checker) checkWorkflow(root *yaml.Node) {
if c.repository != RepositoryAgent && c.repository != RepositoryNezha {
c.reject(RuleRepositoryNotAllowed, at("$", root), fmt.Sprintf("current repository %q is not supported", c.repository))
}
c.checkTriggers(root)
c.checkSecretContexts(root)
c.checkUntrustedExpressions(root)
c.checkForbiddenEnvironment(root)
c.checkPermissions(root, "$.permissions", true)
concurrency, exists := mappingValue(root, "concurrency")
if !exists || !validConcurrency(concurrency) {
node := root
if exists {
node = concurrency
}
detail := "workflow concurrency requires a nonempty group"
if exists {
if _, hasGroup := mappingValue(concurrency, "group"); hasGroup {
cancel, hasCancel := mappingValue(concurrency, "cancel-in-progress")
if hasCancel && (cancel.Kind != yaml.ScalarNode || cancel.Tag != "!!bool") {
detail = "workflow concurrency cancel-in-progress must be a boolean"
}
}
}
c.reject(RuleMissingConcurrency, at("$.concurrency", node), detail)
}
jobs, exists := mappingValue(root, "jobs")
if !exists || jobs.Kind != yaml.MappingNode || len(jobs.Content) == 0 {
node := root
if exists {
node = jobs
}
c.reject(RuleWorkflowStructure, at("$.jobs", node), "workflow jobs must be a nonempty mapping")
return
}
for _, entry := range mappingEntries(jobs) {
if entry[1].Kind != yaml.MappingNode {
c.reject(RuleWorkflowStructure, at("$.jobs."+entry[0].Value, entry[1]), "workflow job must be a mapping")
continue
}
c.checkJob(entry[0].Value, entry[1])
}
c.checkRequiredAggregator(jobs)
}
func (c *checker) checkForbiddenEnvironment(root *yaml.Node) {
walkMappings(root, func(mapping *yaml.Node) {
environment, exists := mappingValue(mapping, "env")
if !exists || environment.Kind != yaml.MappingNode {
return
}
for _, entry := range mappingEntries(environment) {
if strings.HasPrefix(strings.ToUpper(entry[0].Value), "GIT_") {
c.reject(RuleRepositoryNotLiteral, at("$.env."+entry[0].Value, entry[0]), fmt.Sprintf("Git configuration environment %s is forbidden", entry[0].Value))
}
}
})
}
func validConcurrency(node *yaml.Node) bool {
if value, literal := scalarString(node); literal {
return strings.TrimSpace(value) != ""
}
group, exists := mappingValue(node, "group")
if !exists {
return false
}
value, literal := scalarString(group)
if !literal || strings.TrimSpace(value) == "" {
return false
}
cancel, exists := mappingValue(node, "cancel-in-progress")
return !exists || (cancel.Kind == yaml.ScalarNode && cancel.Tag == "!!bool")
}
func (c *checker) checkTriggers(root *yaml.Node) {
trigger, exists := mappingValue(root, "on")
if !exists {
return
}
for _, forbidden := range []string{"pull_request_target", "workflow_run"} {
if containsScalar(trigger, forbidden) {
c.reject(RulePrivilegedTrigger, at("$.on."+forbidden, trigger), fmt.Sprintf("privileged trigger %s is forbidden", forbidden))
}
}
}
func (c *checker) checkSecretContexts(root *yaml.Node) {
walkScalars(root, func(node *yaml.Node) {
if strings.Contains(node.Value, "${{") && (secretContextPattern.MatchString(node.Value) || tokenSourcePattern.MatchString(node.Value)) {
detail := "secrets context is forbidden"
if tokenSourcePattern.MatchString(node.Value) {
detail = "github.token secret source is forbidden"
}
c.reject(RuleSecretContext, at("$", node), detail)
}
})
walkMappings(root, func(mapping *yaml.Node) {
for _, entry := range mappingEntries(mapping) {
if strings.EqualFold(entry[0].Value, "GITHUB_TOKEN") {
c.reject(RuleSecretContext, at("$.env.GITHUB_TOKEN", entry[0]), "GITHUB_TOKEN secret source is forbidden")
}
}
})
}
func (c *checker) checkUntrustedExpressions(root *yaml.Node) {
walkScalars(root, func(node *yaml.Node) {
if expression := untrustedExpressionPattern.FindString(node.Value); expression != "" {
c.reject(RuleUntrustedExpression, at("$", node), fmt.Sprintf("untrusted github event expression %s is forbidden", expression))
}
})
}
func (c *checker) checkPermissions(mapping *yaml.Node, path string, required bool) {
permissions, exists := mappingValue(mapping, "permissions")
if !exists {
if required {
c.reject(RuleWritePermission, at(path, mapping), "root permissions must be explicitly read-only")
}
return
}
if permissions.Kind == yaml.ScalarNode {
if permissions.Value != "read-all" {
c.reject(RuleWritePermission, at(path, permissions), fmt.Sprintf("permissions must be read-only, got %q", permissions.Value))
}
return
}
if permissions.Kind != yaml.MappingNode {
c.reject(RuleWritePermission, at(path, permissions), "permissions must be read-all or a read-only mapping")
return
}
for _, entry := range mappingEntries(permissions) {
if entry[1].Kind != yaml.ScalarNode {
c.reject(RuleWritePermission, at(path+"."+entry[0].Value, entry[1]), "permission value must be a scalar read or none")
continue
}
value := strings.ToLower(strings.TrimSpace(entry[1].Value))
if value != "read" && value != "none" {
c.reject(RuleWritePermission, at(path+"."+entry[0].Value, entry[1]), fmt.Sprintf("permission %s must be read or none, got %q", entry[0].Value, entry[1].Value))
}
}
}
func (c *checker) reject(rule Rule, location violationLocation, detail string) {
c.violations = append(c.violations, Violation{
Rule: rule, Path: location.path, Line: location.node.Line, Column: location.node.Column,
Detail: detail,
})
}