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.
Fuzz the metadata and workflow parsers
d9f5a930d9e6aa20b5dbfb00d154afd46df901ab · Keenan Nemetz · 10d ago · parent 16bf0ebf
Fuzz the metadata and workflow parsers A push decides how much of this code runs: frontmatter becomes the object every page renders, and workflow YAML becomes the job the runner executes. Neither was fuzzed, and both accept bytes from anyone allowed to push. Four targets, each asserting the property that makes the pair of reader and writer safe rather than waiting for a panic: an accepted issue or comment must render and re-parse identically, and a second write must not change a byte; an accepted workflow must be one the runner can act on; an accepted image definition must not add a line to the Dockerfile it renders. FuzzParseComment found one asymmetry in under a second: a hand-written file without a trailing newline is normalized on the first write. That is the format working as designed, since every writer trims and nothing compares raw bodies, so the property now says so and the input is committed as a seed. The YAML bomb is pinned by a test with two assertions, because the parser refuses it on the unknown keys before expansion is reached, and it is yaml.v3's own aliasing limit that covers a bomb inside a field the schema knows. Closes #97.
ci/fuzz_test.go +160
| @@ -0,0 +1,160 @@ | |||
| 1 | package ci | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | |||
| 7 | "gopkg.in/yaml.v3" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // FuzzParseWorkflow drives the decoder that turns a pushed workflow file into the | ||
| 11 | // job the runner is handed. The file arrives from whoever can push, so a panic is | ||
| 12 | // an outage and a document that half-validates is worse: the runner executes what | ||
| 13 | // the parser produced, not what the author meant. | ||
| 14 | // | ||
| 15 | // Parse is all-or-nothing by design — a problem anywhere means no workflow — so | ||
| 16 | // the properties are about that contract holding for every input. | ||
| 17 | func FuzzParseWorkflow(f *testing.F) { | ||
| 18 | for _, seed := range []string{ | ||
| 19 | "version: 1\non:\n push:\n branches: ['*']\njobs:\n verify:\n image: golang:1.27-bookworm\n steps:\n - name: build\n run: go build ./...\n", | ||
| 20 | "version: 1\non:\n push:\n tags: ['v*']\njobs:\n release:\n image-file: release\n steps:\n - run: make dist\n", | ||
| 21 | "version: 1\njobs:\n a:\n image: alpine\n steps:\n - run: echo\n", | ||
| 22 | "version: 1\njobs:\n a:\n steps:\n - run: echo\n", | ||
| 23 | // Unknown fields, wrong types, an unsupported version, and a job with no | ||
| 24 | // steps are the shapes a first attempt produces. | ||
| 25 | "version: 1\njobs:\n a:\n image: alpine\n steps: []\n srcdir: /x\n", | ||
| 26 | "version: 2\njobs:\n a:\n image: alpine\n steps:\n - run: echo\n", | ||
| 27 | "version: 1\njobs: []\n", | ||
| 28 | "version: 1\njobs:\n a:\n image: alpine\n steps: not-a-list\n", | ||
| 29 | "version: 1\njobs: {}\n", | ||
| 30 | // Tabs are not YAML indentation, and the message a person gets for it is the | ||
| 31 | // first thing they read. | ||
| 32 | "version: 1\njobs:\n\ta:\n\t\timage: alpine\n", | ||
| 33 | "", | ||
| 34 | "---\n---\n", | ||
| 35 | "version: 1\non: {push: {branches: &a ['*'], tags: [*a]}}\njobs:\n a:\n image: x\n steps: [{run: y}]\n", | ||
| 36 | } { | ||
| 37 | f.Add(seed) | ||
| 38 | } | ||
| 39 | f.Fuzz(func(t *testing.T, content string) { | ||
| 40 | wf, problems := Parse(".vole/workflows/fuzz.yaml", content) | ||
| 41 | if wf == nil { | ||
| 42 | if len(problems) == 0 { | ||
| 43 | t.Fatalf("%q was refused without saying why", content) | ||
| 44 | } | ||
| 45 | return | ||
| 46 | } | ||
| 47 | // An accepted workflow becomes a job the runner executes, so it must be one | ||
| 48 | // the runner can act on at all. | ||
| 49 | if len(problems) > 0 { | ||
| 50 | t.Fatalf("%q was accepted with problems: %v", content, problems) | ||
| 51 | } | ||
| 52 | if wf.Version != Version { | ||
| 53 | t.Fatalf("%q was accepted as version %d", content, wf.Version) | ||
| 54 | } | ||
| 55 | if len(wf.Jobs) == 0 { | ||
| 56 | t.Fatalf("%q was accepted with no jobs", content) | ||
| 57 | } | ||
| 58 | for _, job := range wf.Jobs { | ||
| 59 | // A job that names neither an image nor a definition has nothing to run | ||
| 60 | // in; the docs say that fails the push. | ||
| 61 | if job.Image == "" && job.ImageFile == "" { | ||
| 62 | t.Fatalf("%q was accepted with a job that names no image", content) | ||
| 63 | } | ||
| 64 | } | ||
| 65 | // The same file has to validate the same way twice: a push that is refused | ||
| 66 | // on a retry, or whose error list shuffles between runs, is a bug report | ||
| 67 | // nobody can reproduce. | ||
| 68 | wf2, problems2 := Parse(".vole/workflows/fuzz.yaml", content) | ||
| 69 | if (wf2 == nil) != (wf == nil) || len(problems2) != len(problems) { | ||
| 70 | t.Fatalf("%q validated two different ways", content) | ||
| 71 | } | ||
| 72 | // Glob matching runs on patterns this file supplied. | ||
| 73 | wf.Applies("main") | ||
| 74 | wf.AppliesToTag("v1.0.0") | ||
| 75 | }) | ||
| 76 | } | ||
| 77 | |||
| 78 | // FuzzParseImage is the same for a workflow image definition, which ends up as a | ||
| 79 | // Dockerfile the runner builds. The property that matters is that the definition | ||
| 80 | // cannot add an instruction: the env and run values go through quoting, and a | ||
| 81 | // value that broke out of it would be one line of a Dockerfile the author did not | ||
| 82 | // write. | ||
| 83 | func FuzzParseImage(f *testing.F) { | ||
| 84 | for _, seed := range []string{ | ||
| 85 | "version: 1\nfrom: golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5\n", | ||
| 86 | "version: 1\nfrom: alpine\nenv:\n CGO_ENABLED: '1'\nrun:\n - apt-get update\n - useradd ci\n", | ||
| 87 | "version: 1\nfrom: alpine\nenv:\n EVIL: 'x\"\\nRUN curl http://example.invalid'\n", | ||
| 88 | "version: 1\nfrom: alpine\nrun:\n - 'x\"] && curl http://example.invalid'\n", | ||
| 89 | "version: 1\nfrom: alpine\nenv:\n ONE_LINE: 'a\nb'\n", | ||
| 90 | "version: 1\nfrom: ''\n", | ||
| 91 | "version: 1\nfrom: alpine\nenv:\n not a name: x\n", | ||
| 92 | "version: 2\nfrom: alpine\n", | ||
| 93 | "from: alpine\n", | ||
| 94 | "", | ||
| 95 | "version: 1\nfrom: [a, b]\n", | ||
| 96 | } { | ||
| 97 | f.Add(seed) | ||
| 98 | } | ||
| 99 | f.Fuzz(func(t *testing.T, content string) { | ||
| 100 | def, problems := ParseImage(".vole/images/fuzz.yaml", content) | ||
| 101 | if def == nil { | ||
| 102 | if len(problems) == 0 { | ||
| 103 | t.Fatalf("%q was refused without saying why", content) | ||
| 104 | } | ||
| 105 | return | ||
| 106 | } | ||
| 107 | if len(problems) > 0 { | ||
| 108 | t.Fatalf("%q was accepted with problems: %v", content, problems) | ||
| 109 | } | ||
| 110 | if def.Version != 1 { | ||
| 111 | t.Fatalf("%q was accepted as version %d", content, def.Version) | ||
| 112 | } | ||
| 113 | if err := ValidImageRef(def.From); err != nil { | ||
| 114 | t.Fatalf("%q was accepted with from %q: %v", content, def.From, err) | ||
| 115 | } | ||
| 116 | rendered, err := RenderImage(content) | ||
| 117 | if err != nil { | ||
| 118 | t.Fatalf("an accepted definition does not render: %v", err) | ||
| 119 | } | ||
| 120 | // One header, one FROM, then one line for each env entry and each run | ||
| 121 | // command. A value that broke out of its quoting would add a line, which is | ||
| 122 | // exactly the instruction nobody wrote. | ||
| 123 | wantLines := 2 + len(def.Env) + len(def.Run) | ||
| 124 | if lines := strings.Count(rendered, "\n"); lines != wantLines { | ||
| 125 | t.Fatalf("%q rendered %d lines, want %d:\n%s", content, lines, wantLines, rendered) | ||
| 126 | } | ||
| 127 | }) | ||
| 128 | } | ||
| 129 | |||
| 130 | // TestParseRefusesUnboundedAliasing pins the reason a YAML bomb is not a problem | ||
| 131 | // here, because the protection is not visible in this file: yaml.v3 refuses a | ||
| 132 | // document whose aliases expand beyond a ratio it can afford. | ||
| 133 | // | ||
| 134 | // Two things are checked, and they fail for different reasons, so both are | ||
| 135 | // asserted rather than one standing in for the other. | ||
| 136 | func TestParseRefusesUnboundedAliasing(t *testing.T) { | ||
| 137 | t.Parallel() | ||
| 138 | // Nine levels of nine-fold expansion: about 387 million nodes if it were | ||
| 139 | // expanded, from a few hundred bytes. | ||
| 140 | bomb := "version: 1\n" + | ||
| 141 | "a: &a [x, x, x, x, x, x, x, x, x]\n" + | ||
| 142 | "b: &b [*a, *a, *a, *a, *a, *a, *a, *a, *a]\n" + | ||
| 143 | "c: &c [*b, *b, *b, *b, *b, *b, *b, *b, *b]\n" + | ||
| 144 | "d: &d [*c, *c, *c, *c, *c, *c, *c, *c, *c]\n" + | ||
| 145 | "e: [*d, *d, *d, *d, *d, *d, *d, *d, *d]\n" | ||
| 146 | if wf, problems := Parse(".vole/workflows/bomb.yaml", bomb); wf != nil || len(problems) == 0 { | ||
| 147 | t.Fatalf("the parser accepted a document with unknown keys: %v", wf) | ||
| 148 | } | ||
| 149 | // The parser above refuses on the unknown keys before expansion is reached, so | ||
| 150 | // the decoder's own limit is what protects a bomb inside a field the schema | ||
| 151 | // does know. That limit is why this code can decode repository YAML at all. | ||
| 152 | var v any | ||
| 153 | err := yaml.NewDecoder(strings.NewReader(bomb)).Decode(&v) | ||
| 154 | if err == nil { | ||
| 155 | t.Fatal("the decoder expanded a bomb without complaint") | ||
| 156 | } | ||
| 157 | if !strings.Contains(err.Error(), "excessive aliasing") { | ||
| 158 | t.Fatalf("the decoder refused the bomb for another reason: %v", err) | ||
| 159 | } | ||
| 160 | } | ||