vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
f5456470
Keenan Nemetz
12d ago
|
1 | package agent |
| 2 | ||
| 3 | // Validation of a leased job. | |
| 4 | // | |
| 5 | // A job arrives from the instance over SSH. The server validated the workflow when | |
| 6 | // it was pushed, but the runner is the party that acts on the job, and the | |
| 7 | // channel is a trust boundary: a compromised or buggy instance, or a MITM that | |
| 8 | // got past a loose host-key check, would otherwise be able to name a repository | |
| 9 | // outside the cache, point the worktree at a commit that is not a commit, or aim | |
| 10 | // a site deploy at a directory above the checkout. So every field the runner | |
| 11 | // builds a path or a command out of is checked again here, before any of it is | |
| 12 | // used. | |
| 13 | ||
| 14 | import ( | |
|
eb07ff56
Keenan Nemetz
8d ago
|
15 | "encoding/json" |
|
f5456470
Keenan Nemetz
12d ago
|
16 | "errors" |
| 17 | "fmt" | |
| 18 | "strings" | |
| 19 | "time" | |
| 20 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
21 | "rodent.software/vole/ci" |
| 22 | "rodent.software/vole/core/id" | |
| 23 | "rodent.software/vole/forge/repo" | |
|
f5456470
Keenan Nemetz
12d ago
|
24 | ) |
| 25 | ||
| 26 | // validateJob reports every reason a leased job must not be run. It is called | |
| 27 | // before the checkout, the container, the cache mount and the artifact patterns | |
| 28 | // exist, so a refusal costs nothing but the message. | |
| 29 | func validateJob(job *runnerJob) error { | |
| 30 | if job == nil { | |
| 31 | return errors.New("the lease carried no job") | |
| 32 | } | |
| 33 | var problems []string | |
| 34 | add := func(format string, args ...any) { | |
| 35 | problems = append(problems, fmt.Sprintf(format, args...)) | |
| 36 | } | |
| 37 | ||
| 38 | if !id.Valid(job.ID) { | |
| 39 | add("run id %q is not a run id", job.ID) | |
| 40 | } | |
| 41 | if err := repo.ValidateName(job.Repo); err != nil { | |
| 42 | add("repository %q: %v", job.Repo, err) | |
| 43 | } | |
| 44 | if job.Attempt < 1 { | |
| 45 | add("attempt %d is not a positive attempt number", job.Attempt) | |
| 46 | } | |
| 47 | if !ci.ValidObjectID(job.SHA) { | |
| 48 | add("commit %q is not a commit id", job.SHA) | |
| 49 | } | |
| 50 | if !ci.ValidRef(job.Ref) { | |
| 51 | add("ref %q is not a branch or tag", job.Ref) | |
| 52 | } | |
| 53 | if job.Branch != "" && !ci.ValidRefName(job.Branch) { | |
| 54 | add("branch %q is not a branch name", job.Branch) | |
| 55 | } | |
| 56 | if job.Tag != "" && !ci.ValidRefName(job.Tag) { | |
| 57 | add("tag %q is not a tag name", job.Tag) | |
| 58 | } | |
| 59 | if job.WorkflowSHA != "" && !ci.ValidObjectID(job.WorkflowSHA) { | |
| 60 | add("workflow revision %q is not a commit id", job.WorkflowSHA) | |
| 61 | } | |
| 62 | if job.StepsSHA != "" && !isDigest(job.StepsSHA) { | |
| 63 | add("steps digest %q is not a digest", job.StepsSHA) | |
| 64 | } | |
| 65 | for _, field := range []struct{ name, value string }{ | |
| 66 | {"workflow", job.Workflow}, | |
| 67 | {"job", job.Job}, | |
| 68 | } { | |
| 69 | if field.value == "" || len(field.value) > 256 || strings.ContainsAny(field.value, "\x00\r\n") { | |
| 70 | add("%s name %q is not a name", field.name, field.value) | |
| 71 | } | |
| 72 | } | |
|
eb07ff56
Keenan Nemetz
8d ago
|
73 | switch job.Trigger { |
| 74 | case "", "push", "change_request", "issue", "comment", "schedule", "workflow_dispatch": | |
| 75 | default: | |
| 76 | add("trigger %q is not an event this runner understands", job.Trigger) | |
| 77 | } | |
| 78 | if job.TriggerAction != "" && (len(job.TriggerAction) > 64 || strings.ContainsAny(job.TriggerAction, "\x00\r\n")) { | |
| 79 | add("trigger action %q is not one line of text", job.TriggerAction) | |
| 80 | } | |
|
e7aa0785
Keenan Nemetz
6d ago
|
81 | if job.Timeout != "" { |
| 82 | d, err := time.ParseDuration(job.Timeout) | |
| 83 | if err != nil || d <= 0 || d > ci.MaxTimeout { | |
| 84 | add("timeout %q is not a duration between 0 and %s", job.Timeout, ci.MaxTimeout) | |
| 85 | } | |
| 86 | } | |
|
eb07ff56
Keenan Nemetz
8d ago
|
87 | if job.EventPayload != "" { |
| 88 | switch { | |
| 89 | case len(job.EventPayload) > ci.MaxEventPayloadBytes: | |
| 90 | add("event payload is %d bytes, limit %d", len(job.EventPayload), ci.MaxEventPayloadBytes) | |
| 91 | case !json.Valid([]byte(job.EventPayload)): | |
| 92 | add("event payload is not JSON") | |
| 93 | } | |
| 94 | } | |
|
f5456470
Keenan Nemetz
12d ago
|
95 | |
| 96 | if job.Image == "" { | |
| 97 | add("the job names no image") | |
| 98 | } else if name, ok := strings.CutPrefix(job.Image, "file:"); ok { | |
| 99 | // An image-file definition is named, not pathed: the runner resolves the name | |
| 100 | // under .vole/images itself, so a name with a slash or a dot-dot could not | |
| 101 | // reach outside the checkout and is refused anyway. | |
| 102 | if _, err := ci.ImageName(name); err != nil { | |
| 103 | add("image: %v", err) | |
| 104 | } | |
| 105 | if job.ImageFile != name { | |
| 106 | add("image file %q disagrees with the image reference %q", job.ImageFile, job.Image) | |
| 107 | } | |
| 108 | if !isDigest(job.ImageSHA) { | |
| 109 | add("image file %q has no pinned digest", job.ImageFile) | |
| 110 | } | |
| 111 | } else if err := ci.ValidImageRef(job.Image); err != nil { | |
| 112 | add("image: %v", err) | |
| 113 | } else if job.ImageFile != "" || job.ImageSHA != "" { | |
| 114 | add("image %q names a definition but is not a file: reference", job.Image) | |
| 115 | } | |
| 116 | ||
| 117 | if len(job.Steps) > ci.MaxSteps { | |
| 118 | add("%d steps, limit %d", len(job.Steps), ci.MaxSteps) | |
| 119 | } | |
| 120 | for i, step := range job.Steps { | |
| 121 | if strings.TrimSpace(step.Name) == "" || len(step.Name) > 256 || | |
| 122 | strings.ContainsAny(step.Name, "\x00\r\n") { | |
| 123 | add("steps[%d]: name %q is not a name", i, step.Name) | |
| 124 | } | |
| 125 | if strings.TrimSpace(step.Run) == "" || len(step.Run) > ci.MaxStepBytes { | |
| 126 | add("steps[%d]: script is empty or longer than %d bytes", i, ci.MaxStepBytes) | |
| 127 | } | |
| 128 | if step.Timeout != "" { | |
| 129 | d, err := time.ParseDuration(step.Timeout) | |
| 130 | if err != nil || d <= 0 || d > ci.MaxTimeout { | |
| 131 | add("steps[%d]: timeout %q is not a duration between 0 and %s", i, step.Timeout, ci.MaxTimeout) | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | for i, pattern := range job.Artifacts { | |
| 137 | if err := ci.ValidateArtifactPath(pattern); err != nil { | |
| 138 | add("artifacts[%d]: %v", i, err) | |
| 139 | } | |
| 140 | } | |
| 141 | for i, pattern := range job.ReleaseArtifacts { | |
| 142 | if err := ci.ValidateArtifactPath(pattern); err != nil { | |
| 143 | add("release_artifacts[%d]: %v", i, err) | |
| 144 | } | |
| 145 | } | |
| 146 | if job.Pages != nil { | |
| 147 | if err := ci.ValidatePagesPath(job.Pages.Path); err != nil { | |
| 148 | add("pages: %v", err) | |
| 149 | } | |
| 150 | if job.Pages.Ref != "" && !ci.ValidRefName(job.Pages.Ref) { | |
| 151 | add("pages: ref %q is not a branch name", job.Pages.Ref) | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | if len(job.Env) > ci.MaxEnv { | |
| 156 | add("%d environment variables, limit %d", len(job.Env), ci.MaxEnv) | |
| 157 | } | |
| 158 | for name, value := range job.Env { | |
| 159 | if !ci.ValidEnvName(name) { | |
| 160 | add("env: %q is not a variable name", name) | |
| 161 | continue | |
| 162 | } | |
| 163 | if len(value) > ci.MaxEnvValue || strings.ContainsAny(value, "\r\n\x00") { | |
| 164 | add("env.%s: the value is too long or not one line", name) | |
| 165 | } | |
| 166 | } | |
| 167 | ||
|
c1929d2f
Keenan Nemetz
5d ago
|
168 | if len(job.Services) > ci.MaxServices { |
| 169 | add("%d services, limit %d", len(job.Services), ci.MaxServices) | |
| 170 | } | |
| 171 | seenServices := map[string]bool{} | |
| 172 | for i, svc := range job.Services { | |
| 173 | where := fmt.Sprintf("services[%d]", i) | |
| 174 | if err := ci.ValidServiceName(svc.ID); err != nil { | |
| 175 | add("%s: %v", where, err) | |
| 176 | continue | |
| 177 | } | |
| 178 | if seenServices[svc.ID] { | |
| 179 | add("%s: service %q is listed twice", where, svc.ID) | |
| 180 | continue | |
| 181 | } | |
| 182 | seenServices[svc.ID] = true | |
| 183 | if err := ci.ValidImageRef(svc.Image); err != nil { | |
| 184 | add("%s.image: %v", where, err) | |
| 185 | } | |
| 186 | if len(svc.Env) > ci.MaxEnv { | |
| 187 | add("%s: %d environment variables, limit %d", where, len(svc.Env), ci.MaxEnv) | |
| 188 | } | |
| 189 | for name, value := range svc.Env { | |
| 190 | if !ci.ValidEnvName(name) { | |
| 191 | add("%s.env: %q is not a variable name", where, name) | |
| 192 | continue | |
| 193 | } | |
| 194 | if len(value) > ci.MaxEnvValue || strings.ContainsAny(value, "\r\n\x00") { | |
| 195 | add("%s.env.%s: the value is too long or not one line", where, name) | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | if len(job.Services) > 0 && job.Network != ci.NetworkBridge { | |
| 200 | add("services need network: bridge") | |
| 201 | } | |
| 202 | ||
|
4ac42680
Keenan Nemetz
7d ago
|
203 | // The values are written into a file the step's shell sources and then into the |
| 204 | // step's environment, so the runner checks what it is about to write rather than | |
| 205 | // trusting the instance to have checked it. A name that could not be a variable, | |
| 206 | // or a value that no process environment can carry, is refused before the | |
| 207 | // checkout exists. | |
| 208 | for name, delivered := range job.Secrets { | |
| 209 | if err := ci.ValidateSecretName(name); err != nil { | |
| 210 | add("secret: %v", err) | |
| 211 | continue | |
| 212 | } | |
| 213 | switch { | |
| 214 | case ci.ReservedEnvName(name): | |
| 215 | add("secret %s would replace a variable the runner sets", name) | |
| 216 | case delivered.Version < 1: | |
| 217 | add("secret %s has no version", name) | |
| 218 | case strings.ContainsRune(delivered.Value, 0): | |
| 219 | add("secret %s contains a NUL byte", name) | |
| 220 | case len(name)+1+len(delivered.Value) > ci.MaxSecretBytes: | |
| 221 | add("secret %s is larger than one environment variable can hold", name) | |
| 222 | } | |
| 223 | } | |
| 224 | for _, issue := range job.Missing { | |
| 225 | if ci.ValidateSecretName(issue.Name) != nil || strings.ContainsAny(issue.Reason, "\x00\r\n") { | |
| 226 | add("missing secret %q is not a name the runner can report", issue.Name) | |
| 227 | } | |
| 228 | } | |
| 229 | ||
|
f5456470
Keenan Nemetz
12d ago
|
230 | if len(problems) == 0 { |
| 231 | return nil | |
| 232 | } | |
| 233 | return fmt.Errorf("refusing job %s from the instance: %s", job.ID, strings.Join(problems, "; ")) | |
| 234 | } | |
| 235 | ||
| 236 | // isDigest reports whether s is a hex SHA-256 digest, which is what the step | |
| 237 | // digest the instance sends is. | |
| 238 | func isDigest(s string) bool { | |
| 239 | if len(s) != 64 { | |
| 240 | return false | |
| 241 | } | |
| 242 | for i := 0; i < len(s); i++ { | |
| 243 | c := s[i] | |
| 244 | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |
| 245 | return false | |
| 246 | } | |
| 247 | } | |
| 248 | return true | |
| 249 | } |