1
0
Fork 0
mirror of https://code.forgejo.org/forgejo/runner.git synced 2025-08-06 17:40:58 +00:00

feat: Validate GitHub Actions schema (#2416)

* feat: Validate GitHub Actions schema

**BREAKING** previously accepted workflows are now invalid

* update code

* fix tests

* Bump docker / fix lint

* fix test action due to moving the file

* remove unused function

* fix parsing additional functions

* fix allow int

* update docker dep, due to linter

(cherry picked from commit 64219df0f2155d75ffc4423dc93c1e80bb4740bc)

Conflicts:
	go.mod
	go.sum
	pkg/model/workflow.go

	trivial context conflict & go.mod upgrades
This commit is contained in:
ChristopherHX 2024-08-13 05:40:21 +02:00 committed by Earl Warren
parent 7eb547faa5
commit 65ae238f17
10 changed files with 2847 additions and 66 deletions

View file

@ -5,6 +5,7 @@ import (
"io"
"strings"
"github.com/nektos/act/pkg/schema"
"gopkg.in/yaml.v3"
)
@ -82,6 +83,18 @@ type Action struct {
} `yaml:"branding"`
}
func (a *Action) UnmarshalYAML(node *yaml.Node) error {
// Validate the schema before deserializing it into our model
if err := (&schema.Node{
Definition: "action-root",
Schema: schema.GetActionSchema(),
}).UnmarshalYAML(node); err != nil {
return err
}
type ActionDefault Action
return node.Decode((*ActionDefault)(a))
}
// Input parameters allow you to specify data that the action expects to use during runtime. GitHub stores input parameters as environment variables. Input ids with uppercase letters are converted to lowercase during runtime. We recommended using lowercase input ids.
type Input struct {
Description string `yaml:"description"`

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/schema"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
@ -92,6 +93,18 @@ func (w *Workflow) OnSchedule() []string {
return []string{}
}
func (w *Workflow) UnmarshalYAML(node *yaml.Node) error {
// Validate the schema before deserializing it into our model
if err := (&schema.Node{
Definition: "workflow-root-strict",
Schema: schema.GetWorkflowSchema(),
}).UnmarshalYAML(node); err != nil {
return err
}
type WorkflowDefault Workflow
return node.Decode((*WorkflowDefault)(w))
}
type WorkflowDispatchInput struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
@ -488,7 +501,7 @@ func (j *Job) GetMatrixes() ([]map[string]interface{}, error) {
return matrixes, nil
}
func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
func commonKeysMatch(a, b map[string]interface{}) bool {
for aKey, aVal := range a {
if bVal, ok := b[aKey]; ok && !reflect.DeepEqual(aVal, bVal) {
return false
@ -497,7 +510,7 @@ func commonKeysMatch(a map[string]interface{}, b map[string]interface{}) bool {
return true
}
func commonKeysMatch2(a map[string]interface{}, b map[string]interface{}, m map[string][]interface{}) bool {
func commonKeysMatch2(a, b map[string]interface{}, m map[string][]interface{}) bool {
for aKey, aVal := range a {
_, useKey := m[aKey]
if bVal, ok := b[aKey]; useKey && ok && !reflect.DeepEqual(aVal, bVal) {

View file

@ -440,15 +440,8 @@ jobs:
uses: ./local-action
`
workflow, err := ReadWorkflow(strings.NewReader(yaml))
assert.NoError(t, err, "read workflow should succeed")
assert.Len(t, workflow.Jobs, 1)
assert.Len(t, workflow.Jobs["test"].Steps, 5)
assert.Equal(t, workflow.Jobs["test"].Steps[0].Type(), StepTypeInvalid)
assert.Equal(t, workflow.Jobs["test"].Steps[1].Type(), StepTypeRun)
assert.Equal(t, workflow.Jobs["test"].Steps[2].Type(), StepTypeUsesActionRemote)
assert.Equal(t, workflow.Jobs["test"].Steps[3].Type(), StepTypeUsesDockerURL)
assert.Equal(t, workflow.Jobs["test"].Steps[4].Type(), StepTypeUsesActionLocal)
_, err := ReadWorkflow(strings.NewReader(yaml))
assert.Error(t, err, "read workflow should fail")
}
// See: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idoutputs

View file

@ -197,16 +197,20 @@ func (j *TestJobFileInfo) runTest(ctx context.Context, t *testing.T, cfg *Config
assert.Nil(t, err, j.workflowPath)
planner, err := model.NewWorkflowPlanner(fullWorkflowPath, true)
assert.Nil(t, err, fullWorkflowPath)
if err != nil {
assert.Error(t, err, j.errorMessage)
} else {
assert.Nil(t, err, fullWorkflowPath)
plan, err := planner.PlanEvent(j.eventName)
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error")
if err == nil && plan != nil {
err = runner.NewPlanExecutor(plan)(ctx)
if j.errorMessage == "" {
assert.Nil(t, err, fullWorkflowPath)
} else {
assert.Error(t, err, j.errorMessage)
plan, err := planner.PlanEvent(j.eventName)
assert.True(t, (err == nil) != (plan == nil), "PlanEvent should return either a plan or an error")
if err == nil && plan != nil {
err = runner.NewPlanExecutor(plan)(ctx)
if j.errorMessage == "" {
assert.Nil(t, err, fullWorkflowPath)
} else {
assert.Error(t, err, j.errorMessage)
}
}
}
@ -331,7 +335,7 @@ func TestRunEvent(t *testing.T) {
config.EventPath = eventFile
}
testConfigFile := filepath.Join(workdir, table.workflowPath, "config.yml")
testConfigFile := filepath.Join(workdir, table.workflowPath, "config/config.yml")
if file, err := os.ReadFile(testConfigFile); err == nil {
testConfig := &TestConfig{}
if yaml.Unmarshal(file, testConfig) == nil {

View file

@ -5,7 +5,7 @@ runs:
using: composite
steps:
# Test if GITHUB_ACTION_PATH is set correctly before all steps
- run: stat $GITHUB_ACTION_PATH/push.yml
- run: stat $GITHUB_ACTION_PATH/../push.yml
shell: bash
- run: stat $GITHUB_ACTION_PATH/action.yml
shell: bash
@ -36,7 +36,7 @@ runs:
with:
who-to-greet: 'Mona the Octocat'
# Test if GITHUB_ACTION_PATH is set correctly after all steps
- run: stat $GITHUB_ACTION_PATH/push.yml
- run: stat $GITHUB_ACTION_PATH/../push.yml
shell: bash
- run: stat $GITHUB_ACTION_PATH/action.yml
shell: bash

View file

@ -6,4 +6,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./local-action-via-composite-dockerfile
- uses: ./local-action-via-composite-dockerfile/action

View file

@ -0,0 +1,261 @@
{
"definitions": {
"action-root": {
"description": "Action file",
"mapping": {
"properties": {
"name": "string",
"description": "string",
"inputs": "inputs",
"runs": "runs",
"outputs": "outputs"
},
"loose-key-type": "non-empty-string",
"loose-value-type": "any"
}
},
"inputs": {
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "input"
}
},
"input": {
"mapping": {
"properties": {
"default": "input-default-context"
},
"loose-key-type": "non-empty-string",
"loose-value-type": "any"
}
},
"outputs": {
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "output-definition"
}
},
"output-definition": {
"mapping": {
"properties": {
"description": "string",
"value": "output-value"
}
}
},
"runs": {
"one-of": ["container-runs", "node-runs", "plugin-runs", "composite-runs"]
},
"container-runs": {
"mapping": {
"properties": {
"using": "non-empty-string",
"image": "non-empty-string",
"entrypoint": "non-empty-string",
"args": "container-runs-args",
"env": "container-runs-env",
"pre-entrypoint": "non-empty-string",
"pre-if": "non-empty-string",
"post-entrypoint": "non-empty-string",
"post-if": "non-empty-string"
}
}
},
"container-runs-args": {
"sequence": {
"item-type": "container-runs-context"
}
},
"container-runs-env": {
"context": ["inputs"],
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "string"
}
},
"node-runs": {
"mapping": {
"properties": {
"using": "non-empty-string",
"main": "non-empty-string",
"pre": "non-empty-string",
"pre-if": "non-empty-string",
"post": "non-empty-string",
"post-if": "non-empty-string"
}
}
},
"plugin-runs": {
"mapping": {
"properties": {
"plugin": "non-empty-string"
}
}
},
"composite-runs": {
"mapping": {
"properties": {
"using": "non-empty-string",
"steps": "composite-steps"
}
}
},
"composite-steps": {
"sequence": {
"item-type": "composite-step"
}
},
"composite-step": {
"one-of": ["run-step", "uses-step"]
},
"run-step": {
"mapping": {
"properties": {
"name": "string-steps-context",
"id": "non-empty-string",
"if": "step-if",
"run": {
"type": "string-steps-context",
"required": true
},
"env": "step-env",
"continue-on-error": "boolean-steps-context",
"working-directory": "string-steps-context",
"shell": {
"type": "string-steps-context",
"required": true
}
}
}
},
"uses-step": {
"mapping": {
"properties": {
"name": "string-steps-context",
"id": "non-empty-string",
"if": "step-if",
"uses": {
"type": "non-empty-string",
"required": true
},
"continue-on-error": "boolean-steps-context",
"with": "step-with",
"env": "step-env"
}
}
},
"container-runs-context": {
"context": ["inputs"],
"string": {}
},
"output-value": {
"context": [
"github",
"strategy",
"matrix",
"steps",
"inputs",
"job",
"runner",
"env"
],
"string": {}
},
"input-default-context": {
"context": [
"github",
"strategy",
"matrix",
"job",
"runner",
"hashFiles(1,255)"
],
"string": {}
},
"non-empty-string": {
"string": {
"require-non-empty": true
}
},
"string-steps-context": {
"context": [
"github",
"inputs",
"strategy",
"matrix",
"steps",
"job",
"runner",
"env",
"hashFiles(1,255)"
],
"string": {}
},
"boolean-steps-context": {
"context": [
"github",
"inputs",
"strategy",
"matrix",
"steps",
"job",
"runner",
"env",
"hashFiles(1,255)"
],
"boolean": {}
},
"step-env": {
"context": [
"github",
"inputs",
"strategy",
"matrix",
"steps",
"job",
"runner",
"env",
"hashFiles(1,255)"
],
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "string"
}
},
"step-if": {
"context": [
"github",
"inputs",
"strategy",
"matrix",
"steps",
"job",
"runner",
"env",
"always(0,0)",
"failure(0,0)",
"cancelled(0,0)",
"success(0,0)",
"hashFiles(1,255)"
],
"string": {}
},
"step-with": {
"context": [
"github",
"inputs",
"strategy",
"matrix",
"steps",
"job",
"runner",
"env",
"hashFiles(1,255)"
],
"mapping": {
"loose-key-type": "non-empty-string",
"loose-value-type": "string"
}
}
}
}

387
act/schema/schema.go Normal file
View file

@ -0,0 +1,387 @@
package schema
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"github.com/rhysd/actionlint"
"gopkg.in/yaml.v3"
)
//go:embed workflow_schema.json
var workflowSchema string
//go:embed action_schema.json
var actionSchema string
type Schema struct {
Definitions map[string]Definition
}
func (s *Schema) GetDefinition(name string) Definition {
def, ok := s.Definitions[name]
if !ok {
switch name {
case "any":
return Definition{OneOf: &[]string{"sequence", "mapping", "number", "boolean", "string", "null"}}
case "sequence":
return Definition{Sequence: &SequenceDefinition{ItemType: "any"}}
case "mapping":
return Definition{Mapping: &MappingDefinition{LooseKeyType: "any", LooseValueType: "any"}}
case "number":
return Definition{Number: &NumberDefinition{}}
case "string":
return Definition{String: &StringDefinition{}}
case "boolean":
return Definition{Boolean: &BooleanDefinition{}}
case "null":
return Definition{Null: &NullDefinition{}}
}
}
return def
}
type Definition struct {
Context []string
Mapping *MappingDefinition
Sequence *SequenceDefinition
OneOf *[]string `json:"one-of"`
AllowedValues *[]string `json:"allowed-values"`
String *StringDefinition
Number *NumberDefinition
Boolean *BooleanDefinition
Null *NullDefinition
}
type MappingDefinition struct {
Properties map[string]MappingProperty
LooseKeyType string `json:"loose-key-type"`
LooseValueType string `json:"loose-value-type"`
}
type MappingProperty struct {
Type string
Required bool
}
func (s *MappingProperty) UnmarshalJSON(data []byte) error {
if json.Unmarshal(data, &s.Type) != nil {
type MProp MappingProperty
return json.Unmarshal(data, (*MProp)(s))
}
return nil
}
type SequenceDefinition struct {
ItemType string `json:"item-type"`
}
type StringDefinition struct {
Constant string
IsExpression bool `json:"is-expression"`
}
type NumberDefinition struct {
}
type BooleanDefinition struct {
}
type NullDefinition struct {
}
func GetWorkflowSchema() *Schema {
sh := &Schema{}
_ = json.Unmarshal([]byte(workflowSchema), sh)
return sh
}
func GetActionSchema() *Schema {
sh := &Schema{}
_ = json.Unmarshal([]byte(actionSchema), sh)
return sh
}
type Node struct {
Definition string
Schema *Schema
Context []string
}
type FunctionInfo struct {
name string
min int
max int
}
func (s *Node) checkSingleExpression(exprNode actionlint.ExprNode) error {
if len(s.Context) == 0 {
switch exprNode.Token().Kind {
case actionlint.TokenKindInt:
case actionlint.TokenKindFloat:
case actionlint.TokenKindString:
return nil
default:
return fmt.Errorf("expressions are not allowed here")
}
}
funcs := s.GetFunctions()
var err error
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
for _, v := range *funcs {
if strings.EqualFold(funcCallNode.Callee, v.name) {
if v.min > len(funcCallNode.Args) {
err = errors.Join(err, fmt.Errorf("Missing parameters for %s expected > %v got %v", funcCallNode.Callee, v.min, len(funcCallNode.Args)))
}
if v.max < len(funcCallNode.Args) {
err = errors.Join(err, fmt.Errorf("To many parameters for %s expected < %v got %v", funcCallNode.Callee, v.max, len(funcCallNode.Args)))
}
return
}
}
err = errors.Join(err, fmt.Errorf("Unknown Function Call %s", funcCallNode.Callee))
}
if varNode, ok := node.(*actionlint.VariableNode); entering && ok {
for _, v := range s.Context {
if strings.EqualFold(varNode.Name, v) {
return
}
}
err = errors.Join(err, fmt.Errorf("Unknown Variable Access %s", varNode.Name))
}
})
return err
}
func (s *Node) GetFunctions() *[]FunctionInfo {
funcs := &[]FunctionInfo{}
AddFunction(funcs, "contains", 2, 2)
AddFunction(funcs, "endsWith", 2, 2)
AddFunction(funcs, "format", 1, 255)
AddFunction(funcs, "join", 1, 2)
AddFunction(funcs, "startsWith", 2, 2)
AddFunction(funcs, "toJson", 1, 1)
AddFunction(funcs, "fromJson", 1, 1)
for _, v := range s.Context {
i := strings.Index(v, "(")
if i == -1 {
continue
}
fun := FunctionInfo{
name: v[:i],
}
if n, err := fmt.Sscanf(v[i:], "(%d,%d)", &fun.min, &fun.max); n == 2 && err == nil {
*funcs = append(*funcs, fun)
}
}
return funcs
}
func (s *Node) checkExpression(node *yaml.Node) (bool, error) {
val := node.Value
hadExpr := false
var err error
for {
if i := strings.Index(val, "${{"); i != -1 {
val = val[i+3:]
} else {
return hadExpr, err
}
hadExpr = true
parser := actionlint.NewExprParser()
lexer := actionlint.NewExprLexer(val)
exprNode, parseErr := parser.Parse(lexer)
if parseErr != nil {
err = errors.Join(err, fmt.Errorf("%sFailed to parse: %s", formatLocation(node), parseErr.Message))
continue
}
val = val[lexer.Offset():]
cerr := s.checkSingleExpression(exprNode)
if cerr != nil {
err = errors.Join(err, fmt.Errorf("%s%w", formatLocation(node), cerr))
}
}
}
func AddFunction(funcs *[]FunctionInfo, s string, i1, i2 int) {
*funcs = append(*funcs, FunctionInfo{
name: s,
min: i1,
max: i2,
})
}
func (s *Node) UnmarshalYAML(node *yaml.Node) error {
def := s.Schema.GetDefinition(s.Definition)
if s.Context == nil {
s.Context = def.Context
}
isExpr, err := s.checkExpression(node)
if err != nil {
return err
}
if isExpr {
return nil
}
if def.Mapping != nil {
return s.checkMapping(node, def)
} else if def.Sequence != nil {
return s.checkSequence(node, def)
} else if def.OneOf != nil {
return s.checkOneOf(def, node)
}
if node.Kind != yaml.ScalarNode {
return fmt.Errorf("%sExpected a scalar got %v", formatLocation(node), getStringKind(node.Kind))
}
if def.String != nil {
return s.checkString(node, def)
} else if def.Number != nil {
var num float64
return node.Decode(&num)
} else if def.Boolean != nil {
var b bool
return node.Decode(&b)
} else if def.AllowedValues != nil {
s := node.Value
for _, v := range *def.AllowedValues {
if s == v {
return nil
}
}
return fmt.Errorf("%sExpected one of %s got %s", formatLocation(node), strings.Join(*def.AllowedValues, ","), s)
} else if def.Null != nil {
var myNull *byte
return node.Decode(&myNull)
}
return errors.ErrUnsupported
}
func (s *Node) checkString(node *yaml.Node, def Definition) error {
val := node.Value
if def.String.Constant != "" && def.String.Constant != val {
return fmt.Errorf("%sExpected %s got %s", formatLocation(node), def.String.Constant, val)
}
if def.String.IsExpression {
parser := actionlint.NewExprParser()
lexer := actionlint.NewExprLexer(val + "}}")
exprNode, parseErr := parser.Parse(lexer)
if parseErr != nil {
return fmt.Errorf("%sFailed to parse: %s", formatLocation(node), parseErr.Message)
}
cerr := s.checkSingleExpression(exprNode)
if cerr != nil {
return fmt.Errorf("%s%w", formatLocation(node), cerr)
}
}
return nil
}
func (s *Node) checkOneOf(def Definition, node *yaml.Node) error {
var allErrors error
for _, v := range *def.OneOf {
sub := &Node{
Definition: v,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(v).Context...),
}
err := sub.UnmarshalYAML(node)
if err == nil {
return nil
}
allErrors = errors.Join(allErrors, fmt.Errorf("%sFailed to match %s: %w", formatLocation(node), v, err))
}
return allErrors
}
func getStringKind(k yaml.Kind) string {
switch k {
case yaml.DocumentNode:
return "document"
case yaml.SequenceNode:
return "sequence"
case yaml.MappingNode:
return "mapping"
case yaml.ScalarNode:
return "scalar"
case yaml.AliasNode:
return "alias"
default:
return "unknown"
}
}
func (s *Node) checkSequence(node *yaml.Node, def Definition) error {
if node.Kind != yaml.SequenceNode {
return fmt.Errorf("%sExpected a sequence got %v", formatLocation(node), getStringKind(node.Kind))
}
var allErrors error
for _, v := range node.Content {
allErrors = errors.Join(allErrors, (&Node{
Definition: def.Sequence.ItemType,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(def.Sequence.ItemType).Context...),
}).UnmarshalYAML(v))
}
return allErrors
}
func formatLocation(node *yaml.Node) string {
return fmt.Sprintf("Line: %v Column %v: ", node.Line, node.Column)
}
func (s *Node) checkMapping(node *yaml.Node, def Definition) error {
if node.Kind != yaml.MappingNode {
return fmt.Errorf("%sExpected a mapping got %v", formatLocation(node), getStringKind(node.Kind))
}
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
var allErrors error
for i, k := range node.Content {
if i%2 == 0 {
if insertDirective.MatchString(k.Value) {
if len(s.Context) == 0 {
allErrors = errors.Join(allErrors, fmt.Errorf("%sinsert is not allowed here", formatLocation(k)))
}
continue
}
isExpr, err := s.checkExpression(k)
if err != nil {
allErrors = errors.Join(allErrors, err)
continue
}
if isExpr {
continue
}
vdef, ok := def.Mapping.Properties[k.Value]
if !ok {
if def.Mapping.LooseValueType == "" {
allErrors = errors.Join(allErrors, fmt.Errorf("%sUnknown Property %v", formatLocation(k), k.Value))
continue
}
vdef = MappingProperty{Type: def.Mapping.LooseValueType}
}
if err := (&Node{
Definition: vdef.Type,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(vdef.Type).Context...),
}).UnmarshalYAML(node.Content[i+1]); err != nil {
allErrors = errors.Join(allErrors, err)
continue
}
}
}
return allErrors
}

File diff suppressed because it is too large Load diff