Fuzzing: Move to go fuzzing

Signed-off-by: Julien Pivotto <291750+roidelapluie@users.noreply.github.com>
This commit is contained in:
Julien Pivotto 2025-10-23 17:52:03 +02:00
parent 9cb3641ccd
commit de0a864b5c
7 changed files with 448 additions and 19 deletions

View file

@ -1,30 +1,32 @@
name: CIFuzz name: Fuzzing
on: on:
workflow_call: workflow_call:
permissions: permissions:
contents: read contents: read
jobs: jobs:
Fuzzing: fuzzing:
name: Run Go Fuzz Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
fuzz_test: [FuzzParseMetricText, FuzzParseOpenMetric, FuzzParseMetricSelector, FuzzParseExpr]
steps: steps:
- name: Build Fuzzers - name: Checkout repository
id: build uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@4bf20ff8dfda18ad651583ebca9fb17a7ce1940a # master
with: with:
oss-fuzz-project-name: "prometheus" persist-credentials: false
dry-run: false - name: Install Go
- name: Run Fuzzers uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
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
with: with:
oss-fuzz-project-name: "prometheus" go-version: 1.25.x
fuzz-seconds: 600 - name: Run Fuzzing
dry-run: false run: go test -fuzz=${{ matrix.fuzz_test }}$ -fuzztime=5m ./util/fuzzing
- name: Upload Crash continue-on-error: true
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 id: fuzz
if: failure() && steps.build.outcome == 'success' - name: Upload Crash Artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: failure()
with: with:
name: artifacts name: fuzz-artifacts-${{ matrix.fuzz_test }}
path: ./out/artifacts path: promql/testdata/fuzz/${{ matrix.fuzz_test }}

View file

@ -220,3 +220,8 @@ check-node-version:
bump-go-version: bump-go-version:
@echo ">> bumping Go minor version" @echo ">> bumping Go minor version"
@./scripts/bump_go_version.sh @./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

View file

@ -113,6 +113,39 @@ func NewTestEngineWithOpts(tb testing.TB, opts promql.EngineOpts) *promql.Engine
return ng 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. // RunBuiltinTests runs an acceptance test suite against the provided engine.
func RunBuiltinTests(t TBRun, engine promql.QueryEngine) { func RunBuiltinTests(t TBRun, engine promql.QueryEngine) {
RunBuiltinTestsWithStorage(t, engine, newTestStorage) RunBuiltinTestsWithStorage(t, engine, newTestStorage)

1
util/fuzzing/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
Fuzz*_seed_corpus.zip

122
util/fuzzing/corpus.go Normal file
View file

@ -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
}

View file

@ -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)
}

150
util/fuzzing/fuzz_test.go Normal file
View file

@ -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
})
}