package spindle import ( "context" "strings" "sync" "testing" "time" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/org_tangled" "tangled.org/core/spindle/models" "tangled.org/core/spindle/pipeline" "tangled.org/core/spindle/secrets" "tangled.org/core/workflow" ) const trustWorkflow = "engine: dummy\naudience: " + oidcTestAudience + "\nwhen:\n - event: pull_request\n branch: main\nsteps:\n - name: hi\n command: echo hi\n" type runGrant struct { secrets []string caches int ref string trustedSource bool } func (g runGrant) trusted() bool { return len(g.secrets) > 0 || g.caches > 0 } func (g runGrant) has(key string) bool { for _, k := range g.secrets { if k == key { return true } } return false } type grantEngine struct { mu sync.Mutex grant runGrant ran bool } func (e *grantEngine) InitWorkflow(twf workflow.WorkflowSpec, _ workflow.PipelineSpec) (*models.Workflow, error) { return &models.Workflow{ Name: twf.Name, Steps: []models.Step{recordingStep{}}, Caches: []models.CacheEntry{{Key: "deps", Paths: []string{"node_modules"}}}, }, nil } func (e *grantEngine) SetupWorkflow(context.Context, models.WorkflowId, *models.Workflow, models.WorkflowLogger) error { return nil } func (e *grantEngine) WorkflowTimeout() time.Duration { return time.Minute } func (e *grantEngine) DestroyWorkflow(context.Context, models.WorkflowId) error { return nil } func (e *grantEngine) RunStep(_ context.Context, _ models.WorkflowId, w *models.Workflow, _ int, unlocked []secrets.UnlockedSecret, _ models.WorkflowLogger) error { e.mu.Lock() defer e.mu.Unlock() e.ran = true e.grant = runGrant{caches: len(w.Caches), ref: w.Environment["TANGLED_REF"], trustedSource: w.TrustedSource} for _, s := range unlocked { e.grant.secrets = append(e.grant.secrets, s.Key) } return nil } type trustEnv struct { *prPipelineEnv eng *grantEngine } func newTrustEnv(t *testing.T) *trustEnv { t.Helper() e := newPRPipelineEnv(t) addTestSecret(t, e.n.s, testRepoDid) e.n.s.oidc = oidcTestIssuer(t) eng := &grantEngine{} e.n.s.engs = map[string]models.Engine{"dummy": eng} return &trustEnv{prPipelineEnv: e, eng: eng} } func (e *trustEnv) head(t *testing.T) string { t.Helper() return e.repo.commitWorkflows(t, map[string]string{"pr.yaml": trustWorkflow}) } // a patch whose record names where its head came from, the way submitPatch writes it func (e *trustEnv) patchFrom(rkey, ticket, head string, editor, source syntax.DID, branch string) { record := e.putPatch(testRepoDid, rkey, ticket, head, editor) record["source"] = map[string]any{"repo": source.String(), "branch": branch} e.knot.putVersion(testRepoDid, org_tangled.ReviewPatchNSID, rkey, editor, record) e.n.consumed(e.emitPatch(testRepoDid, rkey, opCreate)) } // runs the pipeline's queued job the way a job worker would func (e *trustEnv) run(t *testing.T, id models.PipelineId) runGrant { t.Helper() ctx := context.Background() for { job, err := e.n.s.db.DequeueJob(ctx, nil) if err != nil { t.Fatal(err) } if job == nil { t.Fatalf("pipeline %s has no queued job", id) } if job.PipelineId != id { continue } e.eng.mu.Lock() e.eng.ran, e.eng.grant = false, runGrant{} e.eng.mu.Unlock() e.n.s.runJob(ctx, job) e.eng.mu.Lock() defer e.eng.mu.Unlock() if !e.eng.ran { t.Fatalf("pipeline %s never ran a step", id) } return e.eng.grant } } func (e *trustEnv) approve(t *testing.T, id models.PipelineId) { t.Helper() if err := e.n.s.ApprovePipeline(context.Background(), testRepoDid, id); err != nil { t.Fatal(err) } } func (e *trustEnv) retry(t *testing.T, id models.PipelineId) models.PipelineId { t.Helper() retried, err := e.n.s.RetryPipeline(context.Background(), testOwner, testRepoDid, id, nil) if err != nil { t.Fatal(err) } return retried } func wantUntrusted(t *testing.T, what string, got runGrant) { t.Helper() if got.trusted() || got.trustedSource || got.has(models.IDTokenEnvVar) { t.Errorf("%s ran with secrets %v, %d caches and TrustedSource %t, want none: its code isn't the repo's", what, got.secrets, got.caches, got.trustedSource) } } func wantTrusted(t *testing.T, what string, got runGrant) { t.Helper() if !got.has("api_key") || !got.has(models.IDTokenEnvVar) || got.caches == 0 || !got.trustedSource { t.Errorf("%s ran with secrets %v, %d caches and TrustedSource %t, want the repo secret, an id token, its caches and TrustedSource", what, got.secrets, got.caches, got.trustedSource) } } func TestForkPullRunsUntrusted(t *testing.T) { e := newTrustEnv(t) e.ticketAs(otherPull, "open", testDave, opCreate) e.patchFrom("bafydaveone", otherPull, e.head(t), testDave, faultOtherRepoDid, "feature") first := expectPRPipelines(t, e.n, 1)[0] wantBlocked(t, e.n, first, true) e.approve(t, first.ID) wantUntrusted(t, "an approved fork pull", e.run(t, first.ID)) wantUntrusted(t, "the retry of an approved fork pull", e.run(t, e.retry(t, first.ID))) e.patchFrom("bafydavetwo", otherPull, e.head(t), testDave, faultOtherRepoDid, "feature") later := expectPRPipelines(t, e.n, 3)[2] wantBlocked(t, e.n, later, true) e.approve(t, later.ID) wantUntrusted(t, "a later round of an approved fork pull", e.run(t, later.ID)) // a collaborator's fork needs no approval, and still isn't the repo's code e.ticketAs(pullRkey, "open", testBob, opCreate) e.patchFrom(patchOne, pullRkey, e.head(t), testBob, faultOtherRepoDid, "feature") own := expectPRPipelines(t, e.n, 4)[3] wantBlocked(t, e.n, own, false) wantUntrusted(t, "a collaborator's fork pull", e.run(t, own.ID)) } func TestSameRepoBranchPullRunsTrusted(t *testing.T) { e := newTrustEnv(t) e.ticketAs(pullRkey, "open", testBob, opCreate) e.patchFrom(patchOne, pullRkey, e.head(t), testBob, testRepoDid, "feature") got := expectPRPipelines(t, e.n, 1)[0] wantBlocked(t, e.n, got, false) wantTrusted(t, "a collaborator's branch of the repo", e.run(t, got.ID)) wantTrusted(t, "the retry of a collaborator's branch", e.run(t, e.retry(t, got.ID))) } // a patch record says what the client claims. nothing checks the branch against the head func TestClaimedBranchOfANonCollaboratorRunsUntrusted(t *testing.T) { e := newTrustEnv(t) e.ticketAs(otherPull, "open", testDave, opCreate) e.patchFrom("bafydaveone", otherPull, e.head(t), testDave, testRepoDid, "main") got := expectPRPipelines(t, e.n, 1)[0] wantBlocked(t, e.n, got, true) e.approve(t, got.ID) run := e.run(t, got.ID) wantUntrusted(t, "an approved pull claiming main", run) if run.ref == "refs/heads/main" { t.Errorf("an approved pull claiming main ran as %s, want it scoped to the pull", run.ref) } wantUntrusted(t, "its retry", e.run(t, e.retry(t, got.ID))) } func (e *trustEnv) enqueue(t *testing.T, id models.PipelineId, trigger workflow.TriggerMetadata, source *workflow.TriggerRepo, blocked bool) models.PipelineId { t.Helper() audience := oidcTestAudience tpl := workflow.PipelineSpec{ TriggerMetadata: &trigger, Workflows: []*workflow.WorkflowSpec{{Name: "pr.yaml", Engine: "dummy", Raw: trustWorkflow, Audience: &audience}}, } record, err := pipeline.FromTangled(id, time.Now(), tpl) if err != nil { t.Fatal(err) } if err := e.n.s.processPipeline(context.Background(), testRepoDid, tpl, record, source, blocked); err != nil { t.Fatal(err) } return id } func (e *trustEnv) triggerRepo() *workflow.TriggerRepo { repoDid := testRepoDid.String() return &workflow.TriggerRepo{Did: testOwner.String(), Knot: "knot.test", RepoDid: &repoDid} } // a stored record still carries whatever branch the patch claimed func TestStoredPullRunsStayUntrusted(t *testing.T) { e := newTrustEnv(t) dave, action := testDave.String(), "opened" pull := "at://" + testRepoDid.String() + "/" + org_tangled.TrackTicketNSID + "/" + otherPull stored := func(id models.PipelineId, branch string, blocked bool) models.PipelineId { return e.enqueue(t, id, workflow.TriggerMetadata{ Kind: string(workflow.TriggerKindPullRequest), Actor: &dave, Repo: e.triggerRepo(), PullRequest: &workflow.PullRequestTriggerData{ Action: &action, Pull: &pull, SourceBranch: branch, SourceSha: e.head(t), TargetBranch: "main", }, }, e.triggerRepo(), blocked) } claimed := stored("3mstoredclaim", "main", true) e.approve(t, claimed) wantUntrusted(t, "a stored pull claiming main, approved", e.run(t, claimed)) wantUntrusted(t, "its retry", e.run(t, e.retry(t, claimed))) fork := stored("3mstoredfork2", "", false) wantUntrusted(t, "a stored fork pull", e.run(t, fork)) wantUntrusted(t, "its retry", e.run(t, e.retry(t, fork))) } func TestPushAndManualRunsKeepTheirTrust(t *testing.T) { e := newTrustEnv(t) owner, sha, ref := testOwner.String(), e.head(t), "refs/heads/main" push := e.enqueue(t, "3mtrustpush22", workflow.TriggerMetadata{ Kind: string(workflow.TriggerKindPush), Actor: &owner, Repo: e.triggerRepo(), Push: &workflow.PushTriggerData{Ref: ref, NewSha: sha, OldSha: strings.Repeat("0", 40)}, }, e.triggerRepo(), false) wantTrusted(t, "a push", e.run(t, push)) fork := faultOtherRepoDid.String() manual := e.enqueue(t, "3mtrustmanual", workflow.TriggerMetadata{ Kind: string(workflow.TriggerKindManual), Actor: &owner, Repo: e.triggerRepo(), Manual: &workflow.ManualTriggerData{Sha: sha, Ref: &ref}, SourceRepo: &fork, }, &workflow.TriggerRepo{RepoDid: &fork, Did: fork, Knot: "knot.test"}, false) wantUntrusted(t, "a manual run of a fork", e.run(t, manual)) }