diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index f9f7abafd6..64c50c3db3 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -1,30 +1,32 @@ -name: CIFuzz +name: Fuzzing on: workflow_call: permissions: contents: read jobs: - Fuzzing: + fuzzing: + name: Run Go Fuzz Tests runs-on: ubuntu-latest + strategy: + matrix: + fuzz_test: [FuzzParseMetricText, FuzzParseOpenMetric, FuzzParseMetricSelector, FuzzParseExpr] steps: - - name: Build Fuzzers - id: build - uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@4bf20ff8dfda18ad651583ebca9fb17a7ce1940a # master + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - oss-fuzz-project-name: "prometheus" - dry-run: false - - name: Run Fuzzers - uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@4bf20ff8dfda18ad651583ebca9fb17a7ce1940a # master - # Note: Regularly check for updates to the pinned commit hash at: - # https://github.com/google/oss-fuzz/tree/master/infra/cifuzz/actions/run_fuzzers + persist-credentials: false + - name: Install Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: - oss-fuzz-project-name: "prometheus" - fuzz-seconds: 600 - dry-run: false - - name: Upload Crash - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - if: failure() && steps.build.outcome == 'success' + go-version: 1.25.x + - name: Run Fuzzing + run: go test -fuzz=${{ matrix.fuzz_test }}$ -fuzztime=5m ./util/fuzzing + continue-on-error: true + id: fuzz + - name: Upload Crash Artifacts + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + if: failure() with: - name: artifacts - path: ./out/artifacts + name: fuzz-artifacts-${{ matrix.fuzz_test }} + path: promql/testdata/fuzz/${{ matrix.fuzz_test }} diff --git a/Makefile b/Makefile index 8c15ceb2e9..8bc4a3dcaa 100644 --- a/Makefile +++ b/Makefile @@ -220,3 +220,8 @@ check-node-version: bump-go-version: @echo ">> bumping Go minor version" @./scripts/bump_go_version.sh + +.PHONY: generate-fuzzing-seed-corpus +generate-fuzzing-seed-corpus: + @echo ">> Generating fuzzing seed corpus" + @$(GO) generate -tags fuzzing ./util/fuzzing/corpus_gen diff --git a/promql/promqltest/test.go b/promql/promqltest/test.go index 1c4226b461..fc3872d197 100644 --- a/promql/promqltest/test.go +++ b/promql/promqltest/test.go @@ -113,6 +113,39 @@ func NewTestEngineWithOpts(tb testing.TB, opts promql.EngineOpts) *promql.Engine return ng } +// GetBuiltInExprs returns all the eval statement expressions from the built-in test files. +func GetBuiltInExprs() ([]string, error) { + files, err := fs.Glob(testsFs, "*/*.test") + if err != nil { + return nil, err + } + + var exprs []string + for _, fn := range files { + content, err := fs.ReadFile(testsFs, fn) + if err != nil { + return nil, err + } + + // Create a minimal test struct just for parsing + testInstance := &test{ + cmds: []testCommand{}, + } + if err := testInstance.parse(string(content)); err != nil { + return nil, err + } + + // Extract expressions from eval commands + for _, cmd := range testInstance.cmds { + if evalCmd, ok := cmd.(*evalCmd); ok { + exprs = append(exprs, evalCmd.expr) + } + } + } + + return exprs, nil +} + // RunBuiltinTests runs an acceptance test suite against the provided engine. func RunBuiltinTests(t TBRun, engine promql.QueryEngine) { RunBuiltinTestsWithStorage(t, engine, newTestStorage) diff --git a/util/fuzzing/.gitignore b/util/fuzzing/.gitignore new file mode 100644 index 0000000000..539a5ec32d --- /dev/null +++ b/util/fuzzing/.gitignore @@ -0,0 +1 @@ +Fuzz*_seed_corpus.zip diff --git a/util/fuzzing/corpus.go b/util/fuzzing/corpus.go new file mode 100644 index 0000000000..52930b2669 --- /dev/null +++ b/util/fuzzing/corpus.go @@ -0,0 +1,122 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fuzzing + +import ( + "github.com/prometheus/prometheus/promql/parser" + "github.com/prometheus/prometheus/promql/promqltest" +) + +// GetCorpusForFuzzParseMetricText returns the seed corpus for FuzzParseMetricText. +func GetCorpusForFuzzParseMetricText() [][]byte { + return [][]byte{ + []byte(""), + []byte("metric_name 1.0"), + []byte("# HELP metric_name help text\n# TYPE metric_name counter\nmetric_name 1.0"), + []byte("o { quantile = \"1.0\", a = \"b\" } 8.3835e-05"), + []byte("# HELP api_http_request_count The total number of HTTP requests.\n# TYPE api_http_request_count counter\nhttp_request_count{method=\"post\",code=\"200\"} 1027 1395066363000"), + []byte("msdos_file_access_time_ms{path=\"C:\\\\DIR\\\\FILE.TXT\",error=\"Cannot find file:\\n\\\"FILE.TXT\\\"\"} 1.234e3"), + []byte("metric_without_timestamp_and_labels 12.47"), + []byte("something_weird{problem=\"division by zero\"} +Inf -3982045"), + []byte("http_request_duration_seconds_bucket{le=\"+Inf\"} 144320"), + []byte("go_gc_duration_seconds{ quantile=\"0.9\", a=\"b\"} 8.3835e-05"), + []byte("go_gc_duration_seconds{ quantile=\"1.0\", a=\"b\" } 8.3835e-05"), + []byte("go_gc_duration_seconds{ quantile = \"1.0\", a = \"b\" } 8.3835e-05"), + } +} + +// GetCorpusForFuzzParseOpenMetric returns the seed corpus for FuzzParseOpenMetric. +func GetCorpusForFuzzParseOpenMetric() [][]byte { + return [][]byte{ + []byte(""), + []byte("# TYPE metric_name counter\nmetric_name_total 1.0"), + []byte("# HELP metric_name help text\n# TYPE metric_name counter\nmetric_name_total 1.0\n# EOF"), + } +} + +// GetCorpusForFuzzParseMetricSelector returns the seed corpus for FuzzParseMetricSelector. +func GetCorpusForFuzzParseMetricSelector() []string { + return []string{ + "", + "metric_name", + `metric_name{label="value"}`, + `{label="value"}`, + `metric_name{label=~"val.*"}`, + } +} + +// GetCorpusForFuzzParseExpr returns the seed corpus for FuzzParseExpr. +func GetCorpusForFuzzParseExpr() ([]string, error) { + // Enable experimental features to parse all test expressions. + parser.EnableExperimentalFunctions = true + parser.ExperimentalDurationExpr = true + parser.EnableExtendedRangeSelectors = true + defer func() { + parser.EnableExperimentalFunctions = false + parser.ExperimentalDurationExpr = false + parser.EnableExtendedRangeSelectors = false + }() + + // Get built-in test expressions. + builtInExprs, err := promqltest.GetBuiltInExprs() + if err != nil { + return nil, err + } + + // Add additional seed corpus. + additionalExprs := []string{ + "", + "1", + "metric_name", + `"str"`, + // Numeric literals + ".5", + "5.", + "123.4567", + "5e3", + "5e-3", + "+5.5e-3", + "0xc", + "0755", + "-0755", + "+Inf", + "-Inf", + // Basic binary operations + "1 + 1", + "1 - 1", + "1 * 1", + "1 / 1", + "1 % 1", + // Comparison operators + "1 == 1", + "1 != 1", + "1 > 1", + "1 >= 1", + "1 < 1", + "1 <= 1", + // Operations with identifiers + "foo == 1", + "foo * bar", + "2.5 / bar", + "foo and bar", + "foo or bar", + // Complex expressions + "+1 + -2 * 1", + "1 + 2/(3*1)", + // Comment + "#comment", + } + + return append(builtInExprs, additionalExprs...), nil +} diff --git a/util/fuzzing/corpus_gen/main.go b/util/fuzzing/corpus_gen/main.go new file mode 100644 index 0000000000..aa38a79a48 --- /dev/null +++ b/util/fuzzing/corpus_gen/main.go @@ -0,0 +1,116 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build fuzzing + +//go:generate go run -tags fuzzing . + +package main + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/prometheus/prometheus/util/fuzzing" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println("Successfully generated all seed corpus ZIP files.") +} + +func run() error { + // Generate FuzzParseExpr seed corpus. + exprs, err := fuzzing.GetCorpusForFuzzParseExpr() + if err != nil { + return fmt.Errorf("failed to get corpus for FuzzParseExpr: %w", err) + } + if err := generateZipFromStrings("fuzzParseExpr", exprs); err != nil { + return fmt.Errorf("failed to generate FuzzParseExpr_seed_corpus.zip: %w", err) + } + fmt.Printf("Generated fuzzParseExpr_seed_corpus.zip with %d entries.\n", len(exprs)) + + // Generate FuzzParseMetricSelector seed corpus. + selectors := fuzzing.GetCorpusForFuzzParseMetricSelector() + if err := generateZipFromStrings("fuzzParseMetricSelector", selectors); err != nil { + return fmt.Errorf("failed to generate FuzzParseMetricSelector_seed_corpus.zip: %w", err) + } + fmt.Printf("Generated fuzzParseMetricSelector_seed_corpus.zip with %d entries.\n", len(selectors)) + + // Generate FuzzParseMetricText seed corpus. + metrics := fuzzing.GetCorpusForFuzzParseMetricText() + if err := generateZipFromBytes("fuzzParseMetricText", metrics); err != nil { + return fmt.Errorf("failed to generate FuzzParseMetricText_seed_corpus.zip: %w", err) + } + fmt.Printf("Generated fuzzParseMetricText_seed_corpus.zip with %d entries.\n", len(metrics)) + + // Generate FuzzParseOpenMetric seed corpus. + openMetrics := fuzzing.GetCorpusForFuzzParseOpenMetric() + if err := generateZipFromBytes("fuzzParseOpenMetric", openMetrics); err != nil { + return fmt.Errorf("failed to generate FuzzParseOpenMetric_seed_corpus.zip: %w", err) + } + fmt.Printf("Generated fuzzParseOpenMetric_seed_corpus.zip with %d entries.\n", len(openMetrics)) + + return nil +} + +// generateZipFromBytes creates a seed corpus ZIP file from a slice of byte slices. +func generateZipFromBytes(fuzzName string, corpus [][]byte) error { + // Sort corpus deterministically. + sorted := make([][]byte, len(corpus)) + copy(sorted, corpus) + sort.Slice(sorted, func(i, j int) bool { + return string(sorted[i]) < string(sorted[j]) + }) + + // Create ZIP file in parent directory. + zipPath := filepath.Join("..", fuzzName+"_seed_corpus.zip") + zipFile, err := os.Create(zipPath) + if err != nil { + return fmt.Errorf("failed to create zip file: %w", err) + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add each corpus entry as a file. + for i, entry := range sorted { + fileName := fmt.Sprintf("expr%d", i) + writer, err := zipWriter.Create(fileName) + if err != nil { + return fmt.Errorf("failed to create zip entry %s: %w", fileName, err) + } + if _, err := writer.Write(entry); err != nil { + return fmt.Errorf("failed to write zip entry %s: %w", fileName, err) + } + } + + return nil +} + +// generateZipFromStrings creates a seed corpus ZIP file from a slice of strings. +func generateZipFromStrings(fuzzName string, corpus []string) error { + // Convert []string to [][]byte and delegate to generateZipFromBytes + byteCorpus := make([][]byte, len(corpus)) + for i, s := range corpus { + byteCorpus[i] = []byte(s) + } + return generateZipFromBytes(fuzzName, byteCorpus) +} diff --git a/util/fuzzing/fuzz_test.go b/util/fuzzing/fuzz_test.go new file mode 100644 index 0000000000..8356fdad71 --- /dev/null +++ b/util/fuzzing/fuzz_test.go @@ -0,0 +1,150 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fuzzing + +import ( + "errors" + "io" + "testing" + + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/model/textparse" + "github.com/prometheus/prometheus/promql/parser" +) + +const ( + // Input size above which we know that Prometheus would consume too much + // memory. The recommended way to deal with it is check input size. + // https://google.github.io/oss-fuzz/getting-started/new-project-guide/#input-size + maxInputSize = 10240 +) + +// Use package-scope symbol table to avoid memory allocation on every fuzzing operation. +var symbolTable = labels.NewSymbolTable() + +// FuzzParseMetricText fuzzes the metric parser with "text/plain" content type. +// +// Note that this is not the parser for the text-based exposition-format; that +// lives in github.com/prometheus/client_golang/text. +func FuzzParseMetricText(f *testing.F) { + // Add seed corpus + for _, corpus := range GetCorpusForFuzzParseMetricText() { + f.Add(corpus) + } + + f.Fuzz(func(t *testing.T, in []byte) { + p, warning := textparse.New(in, "text/plain", symbolTable, textparse.ParserOptions{}) + if p == nil || warning != nil { + // An invalid content type is being passed, which should not happen + // in this context. + t.Skip() + } + + var err error + for { + _, err = p.Next() + if err != nil { + break + } + } + if errors.Is(err, io.EOF) { + err = nil + } + + // We don't care about errors, just that we don't panic. + _ = err + }) +} + +// FuzzParseOpenMetric fuzzes the metric parser with "application/openmetrics-text" content type. +func FuzzParseOpenMetric(f *testing.F) { + // Add seed corpus + for _, corpus := range GetCorpusForFuzzParseOpenMetric() { + f.Add(corpus) + } + + f.Fuzz(func(t *testing.T, in []byte) { + p, warning := textparse.New(in, "application/openmetrics-text", symbolTable, textparse.ParserOptions{}) + if p == nil || warning != nil { + // An invalid content type is being passed, which should not happen + // in this context. + t.Skip() + } + + var err error + for { + _, err = p.Next() + if err != nil { + break + } + } + if errors.Is(err, io.EOF) { + err = nil + } + + // We don't care about errors, just that we don't panic. + _ = err + }) +} + +// FuzzParseMetricSelector fuzzes the metric selector parser. +func FuzzParseMetricSelector(f *testing.F) { + // Add seed corpus + for _, corpus := range GetCorpusForFuzzParseMetricSelector() { + f.Add(corpus) + } + + f.Fuzz(func(t *testing.T, in string) { + if len(in) > maxInputSize { + t.Skip() + } + _, err := parser.ParseMetricSelector(in) + // We don't care about errors, just that we don't panic. + _ = err + }) +} + +// FuzzParseExpr fuzzes the expression parser. +func FuzzParseExpr(f *testing.F) { + parser.EnableExperimentalFunctions = true + parser.ExperimentalDurationExpr = true + parser.EnableExtendedRangeSelectors = true + f.Cleanup(func() { + parser.EnableExperimentalFunctions = false + parser.ExperimentalDurationExpr = false + parser.EnableExtendedRangeSelectors = false + }) + + // Add seed corpus from built-in test expressions + corpus, err := GetCorpusForFuzzParseExpr() + if err != nil { + f.Fatal(err) + } + if len(corpus) < 1000 { + f.Fatalf("loading exprs is likely broken: got %d expressions, expected at least 1000", len(corpus)) + } + + for _, expr := range corpus { + f.Add(expr) + } + + f.Fuzz(func(t *testing.T, in string) { + if len(in) > maxInputSize { + t.Skip() + } + _, err := parser.ParseExpr(in) + // We don't care about errors, just that we don't panic. + _ = err + }) +}