mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
fix(security): harden server and service deletion lifecycle (#1220)
* test: TDD regression tests for GHSA-jx78-55p5-rwv5 stream quota enforcement * Apply remaining changes * fix: update action SHA allowlist and test assertions to match dependabot bump * fix: close GHSA-jx78-55p5-rwv5 incomplete fix of GHSA-qjpp-gffx-2wm9 Finding 1 (Moderate): nil-guard reporterServer in delayCheck and notifyCheck. ServerShared has its own lock independent of serviceResponseDataStoreLock, so m := ServerShared.GetList() taken inside the worker can return a nil entry for the reporter if the server was concurrently deleted. Previously this caused an unrecovered SIGSEGV in the worker goroutine (and in the gRPC layer with no recovery interceptor), taking down the whole instance. Finding 2 (Low): nil-guard ss.services[id] in ServiceSentinel.Delete(). A caller-supplied id that is absent from the registry caused ss.services[id].CronJobID to panic, aborting the Delete loop and leaving every subsequent valid id as a zombie service (DB row deleted, in-memory entry kept, cron probe still running). Regression tests added for both findings following the existing servicesentinel_lifecycle_test.go patterns. * Apply remaining changes * chore: replace commit hashes with version tags in test.yml * fix(server): serialize authoritative lifecycle changes Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(service): bind reports to reporter lifecycle Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(rpc): reject results from stale task streams Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix(agentcompat): allow version-tagged actions * fix(agentcompat): allow literal checkout refs * refactor(agentcompat): remove SHA resolver policy * test(agentcompat): remove resolver SHA fixtures * test(agentcompat): remove mutable ref fixtures * test(agentcompat): use tagged actions in secure fixtures * test(agentcompat): update credential fixtures for tags * test(agentcompat): update reusable action fixtures * test(agentcompat): update artifact redaction fixtures * test(agentcompat): finish artifact fixture tag migration * test(agentcompat): update workflow validation fixtures * test(agentcompat): update dependency workflow fixture * ci(agentcompat): stop pinning cross-repository revisions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: naiba <hi@nai.ba> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
co-authored by
Sisyphus
naiba
parent
bb941e4d73
commit
9ec6164f58
@@ -5,7 +5,6 @@ package workflowpolicy_test
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,8 +14,6 @@ import (
|
||||
|
||||
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")
|
||||
@@ -35,22 +32,21 @@ func TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
require.Equal(t, 75, stressJob.TimeoutMinutes)
|
||||
require.Len(t, stressJob.Steps, 7)
|
||||
|
||||
agentCheckout := stressJob.stepNamed(t, "Checkout Agent revision")
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", agentCheckout.Uses)
|
||||
agentCheckout := stressJob.Steps[0]
|
||||
requireActionRepository(t, agentCheckout.Uses, "actions/checkout")
|
||||
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)
|
||||
nezhaCheckout := stressJob.Steps[1]
|
||||
requireActionRepository(t, nezhaCheckout.Uses, "actions/checkout")
|
||||
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)
|
||||
requireActionRepository(t, setupGo.Uses, "actions/setup-go")
|
||||
require.Equal(t, "^1.26.1", setupGo.With.GoVersion)
|
||||
require.False(t, *setupGo.With.Cache)
|
||||
|
||||
@@ -78,21 +74,11 @@ func TestPolicy_AgentStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
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)
|
||||
|
||||
require.Equal(t, []string{
|
||||
"Checkout Agent revision",
|
||||
"Checkout pinned Nezha revision",
|
||||
"Set up Go",
|
||||
"Prepare Dashboard build inputs",
|
||||
"Require Agent workflow policy tests",
|
||||
"Require named stress test",
|
||||
"Run PR-full agent compatibility stress",
|
||||
}, stepNames(stressJob.Steps))
|
||||
}
|
||||
|
||||
func stepNames(steps []qualityStep) []string {
|
||||
names := make([]string, len(steps))
|
||||
for index, step := range steps {
|
||||
names[index] = step.Name
|
||||
}
|
||||
return names
|
||||
func requireActionRepository(t *testing.T, uses, repository string) {
|
||||
t.Helper()
|
||||
action, _, found := strings.Cut(uses, "@")
|
||||
require.True(t, found)
|
||||
require.Equal(t, repository, action)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func (c *checker) checkCheckout(path string, step *yaml.Node, validatedResolvers map[string]Repository) {
|
||||
func (c *checker) checkCheckout(path string, step *yaml.Node) {
|
||||
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")
|
||||
@@ -36,25 +36,13 @@ func (c *checker) checkCheckout(path string, step *yaml.Node, validatedResolvers
|
||||
return
|
||||
}
|
||||
ref, exists := mappingValue(with, "ref")
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
refValue, literal := scalarString(ref)
|
||||
if exists && literal && fullCommitPattern.MatchString(refValue) {
|
||||
return
|
||||
if !literal || strings.TrimSpace(refValue) == "" || strings.Contains(refValue, "${{") {
|
||||
c.reject(RuleRepositoryNotLiteral, at(path+".with.ref", ref), "checkout ref must be a nonempty literal")
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
)
|
||||
|
||||
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*(?:;|$)`)
|
||||
@@ -56,7 +55,6 @@ func (c *checker) checkJob(name string, job *yaml.Node) {
|
||||
|
||||
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 {
|
||||
@@ -78,15 +76,11 @@ func (c *checker) checkSteps(jobPath string, steps *yaml.Node) {
|
||||
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
|
||||
c.checkRun(path+".run", run)
|
||||
}
|
||||
if hasUses {
|
||||
c.checkUses(path, step, stepCheckState{redactionComplete: redactionReady, validatedResolvers: validatedResolvers})
|
||||
c.checkUses(path, step, stepCheckState{redactionComplete: redactionReady})
|
||||
redactionReady = false
|
||||
continue
|
||||
}
|
||||
@@ -101,7 +95,7 @@ func (c *checker) checkContinueOnError(mapping *yaml.Node, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) checkRun(path string, run *yaml.Node, validatedResolver bool) {
|
||||
func (c *checker) checkRun(path string, run *yaml.Node) {
|
||||
command, exists := scalarString(run)
|
||||
if !exists {
|
||||
return
|
||||
@@ -121,20 +115,19 @@ func (c *checker) checkRun(path string, run *yaml.Node, validatedResolver bool)
|
||||
if gitConfigurationPattern.MatchString(command) {
|
||||
c.reject(RuleRepositoryNotLiteral, at(path, run), "Git configuration mutation is forbidden")
|
||||
}
|
||||
if gitRepositoryCommand.MatchString(command) && !validatedResolver {
|
||||
if gitRepositoryCommand.MatchString(command) {
|
||||
rule := RuleRepositoryNotLiteral
|
||||
detail := fmt.Sprintf("git repository operation %q is allowed only in the validated resolver", strings.TrimSpace(command))
|
||||
detail := fmt.Sprintf("git repository operation %q is forbidden", strings.TrimSpace(command))
|
||||
if !strings.Contains(command, "$") {
|
||||
rule = RuleRepositoryNotAllowed
|
||||
detail = fmt.Sprintf("repository operation %q is forbidden outside the validated resolver", strings.TrimSpace(command))
|
||||
detail = fmt.Sprintf("repository operation %q is forbidden", strings.TrimSpace(command))
|
||||
}
|
||||
c.reject(rule, at(path, run), detail)
|
||||
}
|
||||
}
|
||||
|
||||
type stepCheckState struct {
|
||||
redactionComplete bool
|
||||
validatedResolvers map[string]Repository
|
||||
redactionComplete bool
|
||||
}
|
||||
|
||||
func (c *checker) checkUses(path string, step *yaml.Node, state stepCheckState) {
|
||||
@@ -159,28 +152,23 @@ func (c *checker) checkUses(path string, step *yaml.Node, state stepCheckState)
|
||||
c.reject(RuleReusableExecutable, at(path+".uses", uses), "local action reuse from the workspace is forbidden")
|
||||
return
|
||||
}
|
||||
actionRepository, _, found := strings.Cut(lowerAction, "@")
|
||||
actionRepository, valid := actionRepository(lowerAction)
|
||||
if !valid {
|
||||
c.reject(RuleWorkflowStructure, at(path+".uses", uses), "action reference must use owner/repository@ref syntax")
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
if !approvedAction(actionRepository) {
|
||||
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 {
|
||||
switch actionRepository {
|
||||
case "actions/checkout":
|
||||
c.checkCheckout(path, step, state.validatedResolvers)
|
||||
c.checkCheckout(path, step)
|
||||
case "actions/setup-go":
|
||||
c.checkRequiredCacheDisabled(path, step)
|
||||
case "actions/upload-artifact":
|
||||
@@ -189,19 +177,23 @@ func (c *checker) checkUses(path string, step *yaml.Node, state stepCheckState)
|
||||
c.checkCacheInputs(path, step)
|
||||
}
|
||||
|
||||
func approvedAction(action string) (string, string, bool) {
|
||||
repository, ref, found := strings.Cut(strings.ToLower(action), "@")
|
||||
if !found {
|
||||
return repository, "", false
|
||||
func actionRepository(action string) (string, bool) {
|
||||
repository, ref, found := strings.Cut(action, "@")
|
||||
if !found || repository == "" || ref == "" || strings.Contains(ref, "@") || strings.ContainsAny(action, " \t\r\n") {
|
||||
return "", false
|
||||
}
|
||||
approvedRefs := map[string]string{
|
||||
"actions/checkout": "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0",
|
||||
"actions/setup-go": "924ae3a1cded613372ab5595356fb5720e22ba16",
|
||||
"actions/upload-artifact": "b7c566a772e6b6bfb58ed0dc250532a479d7789f",
|
||||
owner, name, found := strings.Cut(repository, "/")
|
||||
if !found || owner == "" || name == "" || strings.Contains(name, "/") {
|
||||
return "", false
|
||||
}
|
||||
return repository, true
|
||||
}
|
||||
|
||||
func approvedAction(repository string) bool {
|
||||
switch repository {
|
||||
case "actions/checkout", "actions/setup-go", "actions/upload-artifact":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
approvedRef, approved := approvedRefs[repository]
|
||||
if !approved {
|
||||
return "", ref, false
|
||||
}
|
||||
return repository, ref, ref == approvedRef
|
||||
}
|
||||
|
||||
@@ -73,8 +73,31 @@ 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_RejectsActionReferenceWithoutRef(t *testing.T) {
|
||||
assertFixtureRejected(t, rejected("action-reference-missing-ref.yml", workflowpolicy.RuleWorkflowStructure, "owner/repository@ref"))
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsMalformedActionReferences(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uses string
|
||||
rule workflowpolicy.Rule
|
||||
diagnostic string
|
||||
}{
|
||||
{name: "empty ref", uses: "actions/checkout@", rule: workflowpolicy.RuleWorkflowStructure, diagnostic: "owner/repository@ref"},
|
||||
{name: "duplicate separator", uses: "actions/checkout@v7@unexpected", rule: workflowpolicy.RuleWorkflowStructure, diagnostic: "owner/repository@ref"},
|
||||
{name: "extra action path", uses: "actions/checkout/extra@v7", rule: workflowpolicy.RuleWorkflowStructure, diagnostic: "owner/repository@ref"},
|
||||
{name: "whitespace", uses: "actions/checkout @v7", rule: workflowpolicy.RuleWorkflowStructure, diagnostic: "owner/repository@ref"},
|
||||
{name: "dynamic ref", uses: "actions/checkout@${{ inputs.ref }}", rule: workflowpolicy.RuleRepositoryNotLiteral, diagnostic: "action reference must be literal"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workflow := "on:\n pull_request:\nconcurrency: policy\npermissions:\n contents: read\njobs:\n verify:\n runs-on: ubuntu-24.04\n timeout-minutes: 10\n steps:\n - uses: \"" + test.uses + "\"\n with:\n persist-credentials: false\n"
|
||||
err := workflowpolicy.Verify([]byte(workflow), workflowpolicy.RepositoryNezha)
|
||||
requireTypedPolicyError(t, err, test.rule)
|
||||
require.ErrorContains(t, err, test.diagnostic)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsReusableWorkflowJob(t *testing.T) {
|
||||
|
||||
@@ -33,15 +33,42 @@ func TestPolicy_AcceptsSecureAgentWorkflow(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPolicy_AcceptsValidatedResolvedOtherRepositoryRef(t *testing.T) {
|
||||
// Given
|
||||
path := fixturePath(t, "secure-resolved-ref.yml")
|
||||
func TestPolicy_AcceptsCrossRepositoryCheckoutRefs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
repository workflowpolicy.Repository
|
||||
}{
|
||||
{name: "default branch", fixture: "cross-repository-default-ref.yml", repository: workflowpolicy.RepositoryNezha},
|
||||
{name: "branch", fixture: "secure-agent.yml", repository: workflowpolicy.RepositoryAgent},
|
||||
{name: "version tag", fixture: "secure-nezha.yml", repository: workflowpolicy.RepositoryNezha},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := workflowpolicy.VerifyFile(fixturePath(t, test.fixture), test.repository)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// When
|
||||
err := workflowpolicy.VerifyFile(path, workflowpolicy.RepositoryNezha)
|
||||
|
||||
// Then
|
||||
require.NoError(t, err)
|
||||
func TestPolicy_RejectsInvalidCrossRepositoryCheckoutRefs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ref string
|
||||
}{
|
||||
{name: "dynamic", ref: "${{ inputs.ref }}"},
|
||||
{name: "non-string", ref: "false"},
|
||||
{name: "empty", ref: "\"\""},
|
||||
{name: "whitespace only", ref: "\" \""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
workflow := "on:\n pull_request:\nconcurrency: policy\npermissions:\n contents: read\njobs:\n verify:\n runs-on: ubuntu-24.04\n timeout-minutes: 10\n steps:\n - uses: actions/checkout@v7.0.1\n with:\n repository: nezhahq/agent\n ref: " + test.ref + "\n persist-credentials: false\n"
|
||||
err := workflowpolicy.Verify([]byte(workflow), workflowpolicy.RepositoryNezha)
|
||||
requireTypedPolicyError(t, err, workflowpolicy.RuleRepositoryNotLiteral)
|
||||
require.ErrorContains(t, err, "checkout ref must be a nonempty literal")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicy_RejectsPullRequestTarget(t *testing.T) {
|
||||
@@ -104,18 +131,6 @@ 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"))
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ 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.Equal(t, "actions/checkout@v7.0.1", 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, "actions/setup-go@v7", setupGo.Uses)
|
||||
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
|
||||
require.NotNil(t, setupGo.With.Cache)
|
||||
require.False(t, *setupGo.With.Cache)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
const agentcompatStressTestName = "TestStressPRFullEightAgentExactlyOnce"
|
||||
|
||||
func TestPolicy_NezhaStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
func TestPolicy_NezhaStressWorkflowRunsCrossRepositoryTest(t *testing.T) {
|
||||
// Given
|
||||
data := readNezhaQualityWorkflow(t)
|
||||
var workflow qualityWorkflow
|
||||
@@ -27,7 +27,7 @@ func TestPolicy_NezhaStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
require.Len(t, stressJob.Steps, 6)
|
||||
require.Equal(t, []string{
|
||||
"Checkout Nezha revision",
|
||||
"Checkout pinned Agent revision",
|
||||
"Checkout Agent repository",
|
||||
"Set up Go",
|
||||
"Prepare Dashboard build inputs",
|
||||
"Require named stress test",
|
||||
@@ -42,21 +42,21 @@ func TestPolicy_NezhaStressWorkflowRunsPinnedCrossRepositoryTest(t *testing.T) {
|
||||
})
|
||||
|
||||
nezhaCheckout := stressJob.stepNamed(t, "Checkout Nezha revision")
|
||||
require.Equal(t, "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0", nezhaCheckout.Uses)
|
||||
require.Equal(t, "actions/checkout@v7.0.1", 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)
|
||||
agentCheckout := stressJob.stepNamed(t, "Checkout Agent repository")
|
||||
require.Equal(t, "actions/checkout@v7.0.1", agentCheckout.Uses)
|
||||
require.Equal(t, "nezhahq/agent", agentCheckout.With.Repository)
|
||||
require.Equal(t, "667e1dd5e166ffef808ec26dc20de85bc33a0a0f", agentCheckout.With.Ref)
|
||||
require.Empty(t, 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, "actions/setup-go@v7", setupGo.Uses)
|
||||
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
|
||||
require.False(t, *setupGo.With.Cache)
|
||||
|
||||
|
||||
+1
-1
@@ -8,6 +8,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout
|
||||
with:
|
||||
persist-credentials: false
|
||||
+1
-1
@@ -9,6 +9,6 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- run: go test ./...
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/results
|
||||
|
||||
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/cache@0123456789abcdef0123456789abcdef01234567
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: bin
|
||||
key: executable-cache
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ jobs:
|
||||
id: redact-evidence
|
||||
if: false
|
||||
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
|
||||
+1
-2
@@ -8,8 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
+1
-1
@@ -8,4 +8,4 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/download-artifact@0123456789abcdef0123456789abcdef01234567
|
||||
- uses: actions/download-artifact@v7
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
@@ -25,10 +25,10 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.26.x"
|
||||
cache: false
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ jobs:
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
run: go run ./integration/agentcompat/cmd/redact
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: |
|
||||
${{ runner.temp }}/redacted-results
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@ jobs:
|
||||
steps:
|
||||
- name: Redact evidence
|
||||
run: "true"
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/redacted-results
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: ${{ inputs.repository }}
|
||||
ref: 0123456789abcdef0123456789abcdef01234567
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses:
|
||||
- actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- actions/checkout@v7.0.1
|
||||
|
||||
+1
-1
@@ -8,6 +8,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10.5
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
+1
-1
@@ -8,6 +8,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: true
|
||||
|
||||
Vendored
+1
-1
@@ -8,6 +8,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: "false"
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ jobs:
|
||||
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
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ jobs:
|
||||
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
|
||||
- uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
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
|
||||
@@ -9,13 +9,13 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: nezhahq/nezha
|
||||
ref: fedcba9876543210fedcba9876543210fedcba98
|
||||
ref: master
|
||||
path: nezha
|
||||
persist-credentials: false
|
||||
- run: go test ./integration/agentcompat/...
|
||||
|
||||
@@ -15,14 +15,14 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout Nezha
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Checkout Agent
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: 0123456789abcdef0123456789abcdef01234567
|
||||
ref: v1.2.3
|
||||
path: agent
|
||||
persist-credentials: false
|
||||
- name: Test
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
if: always()
|
||||
run: go run ./integration/agentcompat/cmd/redact --output "$RUNNER_TEMP/nezha-agentcompat-redacted"
|
||||
- name: Upload redacted evidence
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
uses: actions/upload-artifact@v6
|
||||
if: always()
|
||||
with:
|
||||
path: ${{ runner.temp }}/nezha-agentcompat-redacted
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
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
|
||||
+1
-1
@@ -8,6 +8,6 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.26.3
|
||||
|
||||
+1
-1
@@ -8,4 +8,4 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: attacker/exfiltrate@0123456789abcdef0123456789abcdef01234567
|
||||
- uses: attacker/exfiltrate@v1
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: attacker/fork
|
||||
ref: 0123456789abcdef0123456789abcdef01234567
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
+1
-1
@@ -11,6 +11,6 @@ jobs:
|
||||
- name: Redact evidence
|
||||
id: redact-evidence
|
||||
run: go run ./integration/agentcompat/cmd/redact
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: ${{ runner.temp }}/results
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
on:
|
||||
pull_request:
|
||||
concurrency: policy
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- id: resolve-agent
|
||||
run: |
|
||||
echo "sha=$(git ls-remote https://github.com/nezhahq/agent.git refs/heads/main | cut -f1)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
repository: nezhahq/agent
|
||||
ref: ${{ steps.resolve-agent.outputs.sha }}
|
||||
persist-credentials: false
|
||||
@@ -24,7 +24,6 @@ const (
|
||||
RuleContainerizedExecution Rule = "containerized-execution"
|
||||
RuleRepositoryNotAllowed Rule = "repository-not-allowed"
|
||||
RuleRepositoryNotLiteral Rule = "repository-not-literal"
|
||||
RuleOtherRepositoryRef Rule = "other-repository-ref"
|
||||
RulePersistCredentials Rule = "persist-credentials" // #nosec G101 -- GitHub Actions configuration key, not a credential.
|
||||
RuleReusableExecutable Rule = "reusable-executable"
|
||||
RuleContinueOnError Rule = "continue-on-error"
|
||||
|
||||
Reference in New Issue
Block a user