mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-07 09:46:03 -10:00
28 files changed
+1361
-8
No files matched your search
@@ -0,0 +1,54 @@
|
||||
name: 🧪 Fuzz
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 0' # Weekly
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
discover-packages:
|
||||
name: Discover fuzz packages
|
||||
if: ${{ github.event_name != 'schedule' || github.repository == 'projectdiscovery/nuclei' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.discover.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
with:
|
||||
go-version-file: go.tool.mod
|
||||
cache-dependency-path: go.tool.sum
|
||||
- id: discover
|
||||
run: make discover-fuzz-packages
|
||||
|
||||
fuzz:
|
||||
name: Fuzz ${{ matrix.pkg }}
|
||||
needs: discover-packages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.discover-packages.outputs.matrix) }}
|
||||
env:
|
||||
GOFUZZ_PACKAGE: ${{ matrix.pkg }}
|
||||
FUZZ_DURATION: 15m
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
with:
|
||||
go-version-file: go.tool.mod
|
||||
cache-dependency-path: go*sum # go.sum + go.tool.sum
|
||||
- id: fuzz
|
||||
run: make fuzz-ci GOFUZZ_PACKAGE="${GOFUZZ_PACKAGE}" FUZZ_DURATION="${FUZZ_DURATION}"
|
||||
- if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.fuzz.outputs.artifact_name }}
|
||||
path: ${{ matrix.pkg }}/.gofuzz
|
||||
if-no-files-found: warn
|
||||
overwrite: true
|
||||
retention-days: 14
|
||||
@@ -18,9 +18,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
with:
|
||||
go-version-file: go.tool.mod
|
||||
cache-dependency-path: go.tool.sum
|
||||
- run: |
|
||||
govulncheck -scan package -format sarif ./... | \
|
||||
go tool -modfile=go.tool.mod govulncheck -scan package -format sarif ./... | \
|
||||
jq '(.runs[].tool.driver.rules[]?.properties.tags)? |= unique' > $OUTPUT
|
||||
- uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
**/*-cache
|
||||
**/*-config
|
||||
**/.cache
|
||||
**/.gofuzz/
|
||||
**/*.DS_Store
|
||||
**/*.exe
|
||||
.devcontainer
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Fuzzing
|
||||
|
||||
Nuclei uses `go-fuzz` for targeted harnesses behind the `gofuzz` build tag.
|
||||
|
||||
## Targets
|
||||
|
||||
The Makefile exposes these go-fuzz targets:
|
||||
|
||||
```bash
|
||||
make build-fuzz GOFUZZ_PACKAGE=./pkg/operators/matchers
|
||||
make fuzz GOFUZZ_PACKAGE=./pkg/operators/matchers
|
||||
```
|
||||
|
||||
Required variable:
|
||||
|
||||
- `GOFUZZ_PACKAGE`: any package path that `go list` can resolve from the repo root. The normal form in this repo is a relative package path such as `./pkg/operators/matchers`.
|
||||
|
||||
`build-fuzz`, `fuzz`, and `fuzz-ci` derive everything else from that package path:
|
||||
|
||||
- workdir: `<package>/.gofuzz/`
|
||||
- seed corpus: `<package>/testdata/gofuzz-corpus/`
|
||||
- instrumented archive: `<package>/.gofuzz/<pkgname>-fuzz.zip`
|
||||
|
||||
`discover-fuzz-packages` emits the GitHub Actions matrix JSON to stdout, and writes `matrix=...` to `GITHUB_OUTPUT` when that file is present.
|
||||
|
||||
`fuzz-ci` wraps `make fuzz` in the CI timeout.
|
||||
|
||||
`build-fuzz` and `fuzz` fail immediately if `GOFUZZ_PACKAGE` is missing, the package cannot be resolved, or the seed corpus directory is missing or empty.
|
||||
|
||||
## Examples
|
||||
|
||||
Operators (matchers & extractors) harness:
|
||||
|
||||
```bash
|
||||
make fuzz GOFUZZ_PACKAGE=./pkg/operators/matchers
|
||||
make fuzz GOFUZZ_PACKAGE=./pkg/operators/extractors
|
||||
```
|
||||
|
||||
**Harness Coverage**:
|
||||
|
||||
Matchers exercise `Matcher.CompileMatchers()` through a compact line-based grammar. Supported keys are:
|
||||
|
||||
- `type`
|
||||
- `condition`
|
||||
- `part`
|
||||
- `encoding`
|
||||
- `negative`
|
||||
- `case-insensitive`
|
||||
- `match-all`
|
||||
- `name`
|
||||
- `value`
|
||||
- `status`
|
||||
- `size`
|
||||
|
||||
Extractors exercise `Extractor.CompileExtractors()` and the matching extraction path with fixed local corpora. Supported keys are:
|
||||
|
||||
- `type`
|
||||
- `part`
|
||||
- `name`
|
||||
- `internal`
|
||||
- `case-insensitive`
|
||||
- `group`
|
||||
- `attribute`
|
||||
- `value`
|
||||
- `regex`
|
||||
- `kval`
|
||||
- `json`
|
||||
- `xpath`
|
||||
- `dsl`
|
||||
|
||||
## Artifacts
|
||||
|
||||
Each run writes under `<package>/.gofuzz/`:
|
||||
|
||||
- `corpus/`: evolving seed corpus used by `go-fuzz`.
|
||||
- `crashers/`: saved crashing inputs.
|
||||
- `*-fuzz.zip`: the instrumented build output created by `go-fuzz-build`.
|
||||
|
||||
Promote real crashers into ordinary regression tests in the owning package once the underlying issue is fixed.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
The repository also has a dedicated GitHub Actions fuzz workflow in `.github/workflows/fuzz.yaml`.
|
||||
|
||||
- Triggers: `workflow_dispatch` and a weekly `schedule`.
|
||||
- Scheduled runs: limited to `projectdiscovery/nuclei`; manual dispatch still works in forks.
|
||||
- Matrix source: any package that has committed seeds under `testdata/gofuzz-corpus/` and a sibling `fuzz.go` harness.
|
||||
- Current package matrix: `./pkg/operators/matchers` and `./pkg/operators/extractors`.
|
||||
- Runtime model: discovery runs `make discover-fuzz-packages`, and each package runs `CI=true make fuzz-ci GOFUZZ_PACKAGE=... FUZZ_DURATION=15m`.
|
||||
- Artifacts: each matrix job uploads the full `<package>/.gofuzz/` as the artifacts.
|
||||
- Failure policy: crashers are summarized and uploaded, but they do not fail the workflow; build/setup errors still fail the job.
|
||||
@@ -7,17 +7,19 @@ GOBUILD_ADDITIONAL_ARGS :=
|
||||
GOMOD := $(GOCMD) mod
|
||||
GOTEST := $(GOCMD) test
|
||||
GOFLAGS := -v
|
||||
GOFUZZ_PACKAGE ?=
|
||||
FUZZ_DURATION ?= 15m
|
||||
# This should be disabled if the binary uses pprof
|
||||
LDFLAGS := -s -w
|
||||
|
||||
ifneq ($(shell go env GOOS),darwin)
|
||||
LDFLAGS += -extldflags "-static"
|
||||
endif
|
||||
|
||||
.PHONY: all build build-stats clean devtools-all devtools-bindgen devtools-scrapefuncs
|
||||
.PHONY: devtools-tsgen docs docgen dsl-docs functional fuzzplayground go-build lint lint-strict syntax-docs
|
||||
|
||||
.PHONY: all build build-stats clean devtools-all devtools-bindgen devtools-scrapefuncs fuzz fuzz-ci fuzz-tools
|
||||
.PHONY: devtools-tsgen docs docgen dsl-docs functional go-build lint lint-strict fuzzplayground syntax-docs
|
||||
.PHONY: integration jsupdate-all jsupdate-bindgen jsupdate-tsgen memogen scan-charts test test-with-lint
|
||||
.PHONY: tidy ts verify download vet template-validate
|
||||
.PHONY: tidy ts verify download vet template-validate build-fuzz discover-fuzz-packages
|
||||
|
||||
all: build
|
||||
|
||||
@@ -132,6 +134,121 @@ fuzzplayground: GOBUILD_PACKAGES = cmd/tools/fuzzplayground/main.go
|
||||
fuzzplayground: LDFLAGS = -s -w
|
||||
fuzzplayground: go-build
|
||||
|
||||
fuzz-tools:
|
||||
@$(GOCMD) tool -modfile=go.tool.mod -n go-fuzz-build >/dev/null
|
||||
@$(GOCMD) tool -modfile=go.tool.mod -n go-fuzz >/dev/null
|
||||
|
||||
discover-fuzz-packages:
|
||||
@set -eu; \
|
||||
entries_file="$$(mktemp)"; \
|
||||
trap 'rm -f "$$entries_file"' EXIT HUP INT TERM; \
|
||||
find . -type d -path '*/testdata/gofuzz-corpus' | LC_ALL=C sort | while IFS= read -r corpus_dir; do \
|
||||
pkg_dir="$${corpus_dir%/testdata/gofuzz-corpus}"; \
|
||||
if [ ! -f "$$pkg_dir/fuzz.go" ]; then \
|
||||
continue; \
|
||||
fi; \
|
||||
pkg_dir="$${pkg_dir#./}"; \
|
||||
pkg="./$$pkg_dir"; \
|
||||
if ! $(GOCMD) list "$$pkg" >/dev/null 2>&1; then \
|
||||
echo "failed to resolve $$pkg"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
jq -cn --arg pkg "$$pkg" '{pkg: $$pkg}' >> "$$entries_file"; \
|
||||
done; \
|
||||
if [ ! -s "$$entries_file" ]; then \
|
||||
echo "no fuzzable packages discovered"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
matrix="$$(jq -cs '{include: .}' "$$entries_file")"; \
|
||||
if [ -n "$${GITHUB_OUTPUT:-}" ]; then \
|
||||
printf 'matrix=%s\n' "$$matrix" >> "$${GITHUB_OUTPUT}"; \
|
||||
else \
|
||||
printf '%s\n' "$$matrix"; \
|
||||
fi
|
||||
|
||||
build-fuzz: fuzz-tools
|
||||
@if [ -z "$(GOFUZZ_PACKAGE)" ]; then echo "GOFUZZ_PACKAGE is required"; exit 1; fi
|
||||
@PACKAGE_DIR="$$( $(GOCMD) list -f '{{.Dir}}' $(GOFUZZ_PACKAGE) 2>/dev/null )" || { \
|
||||
echo "failed to resolve GOFUZZ_PACKAGE: $(GOFUZZ_PACKAGE)"; \
|
||||
exit 1; \
|
||||
}; \
|
||||
WORKDIR="$$PACKAGE_DIR/.gofuzz"; \
|
||||
CORPUS_DIR="$$PACKAGE_DIR/testdata/gofuzz-corpus"; \
|
||||
BIN_PATH="$$WORKDIR/$$(basename "$$PACKAGE_DIR")-fuzz.zip"; \
|
||||
if [ ! -d "$$CORPUS_DIR" ]; then \
|
||||
echo "seed corpus directory does not exist: $$CORPUS_DIR"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
set -- "$$CORPUS_DIR"/*; \
|
||||
if [ "$$1" = "$$CORPUS_DIR/*" ]; then \
|
||||
echo "seed corpus directory has no seed files: $$CORPUS_DIR"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
mkdir -p "$$WORKDIR/corpus"; \
|
||||
cp -f "$$CORPUS_DIR"/* "$$WORKDIR/corpus/"; \
|
||||
GO111MODULE=on $(GOCMD) tool -modfile=go.tool.mod go-fuzz-build -o "$$BIN_PATH" $(GOFUZZ_PACKAGE)
|
||||
|
||||
fuzz: build-fuzz
|
||||
@PACKAGE_DIR="$$( $(GOCMD) list -f '{{.Dir}}' $(GOFUZZ_PACKAGE) 2>/dev/null )" || { \
|
||||
echo "failed to resolve GOFUZZ_PACKAGE: $(GOFUZZ_PACKAGE)"; \
|
||||
exit 1; \
|
||||
}; \
|
||||
WORKDIR="$$PACKAGE_DIR/.gofuzz"; \
|
||||
BIN_PATH="$$WORKDIR/$$(basename "$$PACKAGE_DIR")-fuzz.zip"; \
|
||||
$(GOCMD) tool -modfile=go.tool.mod go-fuzz -bin="$$BIN_PATH" -workdir="$$WORKDIR"
|
||||
|
||||
fuzz-ci:
|
||||
@if [ "$${CI:-}" != "true" ]; then echo "fuzz-ci should be run in CI; use 'make fuzz' locally"; exit 1; fi
|
||||
@if [ -z "$(GOFUZZ_PACKAGE)" ]; then echo "GOFUZZ_PACKAGE is required"; exit 1; fi
|
||||
@set -eu; \
|
||||
pkg_dir="$(GOFUZZ_PACKAGE)"; \
|
||||
pkg_dir="$${pkg_dir#./}"; \
|
||||
workdir="$$pkg_dir/.gofuzz"; \
|
||||
pkg_id="$$(printf '%s' "$$pkg_dir" | tr '/.' '-')"; \
|
||||
artifact_name="gofuzz-$$pkg_id"; \
|
||||
if [ -n "$${GITHUB_OUTPUT:-}" ]; then \
|
||||
printf 'artifact_name=%s\n' "$$artifact_name" >> "$${GITHUB_OUTPUT}"; \
|
||||
fi; \
|
||||
mkdir -p "$$workdir"; \
|
||||
timeout_bin="$$(command -v timeout || command -v gtimeout || true)"; \
|
||||
if [ -z "$$timeout_bin" ]; then \
|
||||
echo "timeout command not found"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
exit_code=0; \
|
||||
"$$timeout_bin" "$(FUZZ_DURATION)" $(MAKE) --no-print-directory fuzz GOFUZZ_PACKAGE="$(GOFUZZ_PACKAGE)" || exit_code=$$?; \
|
||||
if [ "$$exit_code" -ne 0 ] && [ "$$exit_code" -ne 124 ]; then \
|
||||
echo "fuzzing failed with exit code $$exit_code"; \
|
||||
exit "$$exit_code"; \
|
||||
fi; \
|
||||
count_files() { \
|
||||
dir="$$1"; \
|
||||
if [ ! -d "$$dir" ]; then \
|
||||
echo 0; \
|
||||
return; \
|
||||
fi; \
|
||||
if [ "$$(basename "$$dir")" = "crashers" ]; then \
|
||||
find "$$dir" -maxdepth 1 -type f -printf '%f\n' | sed -E 's/\.(output|quoted)$$//' | sort -u | wc -l | tr -d '[:space:]'; \
|
||||
return; \
|
||||
fi; \
|
||||
find "$$dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]'; \
|
||||
}; \
|
||||
crasher_count="$$(count_files "$$workdir/crashers")"; \
|
||||
corpus_count="$$(count_files "$$workdir/corpus")"; \
|
||||
suppressions_count="$$(count_files "$$workdir/suppressions")"; \
|
||||
if [ -n "$${GITHUB_STEP_SUMMARY:-}" ]; then \
|
||||
{ \
|
||||
echo "## \`$(GOFUZZ_PACKAGE)\`"; \
|
||||
echo; \
|
||||
echo "- Corpus: $$corpus_count"; \
|
||||
echo "- Crashers: $$crasher_count"; \
|
||||
echo "- Suppressions: $$suppressions_count"; \
|
||||
echo; \
|
||||
} >> "$${GITHUB_STEP_SUMMARY}"; \
|
||||
else \
|
||||
printf 'Package: %s\nCorpus: %s\nCrashers: %s\nSuppressions: %s\n' "$(GOFUZZ_PACKAGE)" "$$corpus_count" "$$crasher_count" "$$suppressions_count"; \
|
||||
fi
|
||||
|
||||
memogen: GOBUILD_OUTPUT = ./bin/memogen
|
||||
memogen: GOBUILD_PACKAGES = cmd/memogen/memogen.go
|
||||
memogen: go-build
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
module nuclei-tools
|
||||
|
||||
go 1.26.1
|
||||
|
||||
tool (
|
||||
github.com/dvyukov/go-fuzz/go-fuzz
|
||||
github.com/dvyukov/go-fuzz/go-fuzz-build
|
||||
golang.org/x/vuln/cmd/govulncheck
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20240924070022-e577bee5275c // indirect
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1 // indirect
|
||||
github.com/stephens2424/writerset v1.0.2 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/vuln v1.1.4 // indirect
|
||||
)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20240924070022-e577bee5275c h1:oLpHpHwNuAPvw3bBviEZNrJbigNNi5dRadfZnagGgZI=
|
||||
github.com/dvyukov/go-fuzz v0.0.0-20240924070022-e577bee5275c/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robertkrimen/godocdown v0.0.0-20130622164427-0bfa04905481/go.mod h1:C9WhFzY47SzYBIvzFqSvHIR6ROgDo4TtdTuRaOMjF/s=
|
||||
github.com/stephens2424/writerset v1.0.2 h1:znRLgU6g8RS5euYRcy004XeE4W+Tu44kALzy7ghPif8=
|
||||
github.com/stephens2424/writerset v1.0.2/go.mod h1:aS2JhsMn6eA7e82oNmW4rfsgAOp9COBTTl8mzkwADnc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I=
|
||||
golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s=
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package extractors
|
||||
|
||||
import "github.com/projectdiscovery/nuclei/v3/pkg/operators/cache"
|
||||
|
||||
func init() {
|
||||
cache.SetCapacities(128, 128)
|
||||
}
|
||||
|
||||
// Fuzz exercises extractor compilation and extraction with a compact line-based grammar.
|
||||
func Fuzz(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(data) > fuzzMaxInputSize {
|
||||
return -1
|
||||
}
|
||||
|
||||
extractor, ok := extractorFromFuzzData(data)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if err := extractor.CompileExtractors(); err != nil {
|
||||
return 0
|
||||
}
|
||||
exerciseFuzzExtractor(extractor)
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
package extractors
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
fuzzMaxInputSize = 16 << 10
|
||||
fuzzMaxItems = 8
|
||||
fuzzMaxValueBytes = 256
|
||||
)
|
||||
|
||||
var (
|
||||
fuzzExtractorTypes = []ExtractorType{RegexExtractor, KValExtractor, XPathExtractor, JSONExtractor, DSLExtractor}
|
||||
fuzzParts = []string{"", "body", "raw", "all_headers", "header", "response"}
|
||||
fuzzAttributes = []string{"", "href", "content", "id", "name"}
|
||||
fuzzRegexDefaults = []string{`([A-Za-z0-9_]+)`, `token=([a-z0-9]+)`, `https?://[^\s"']+`}
|
||||
fuzzKValDefaults = []string{"content_type", "server", "set_cookie", "x_powered_by"}
|
||||
fuzzJSONDefaults = []string{`.id`, `.items[] | .id`, `.headers.server`, `.links[] | .href`}
|
||||
fuzzXPathDefaults = []string{`//title`, `/html/body/div/p[2]/a`, `//link`, `/root/item`, `//item`}
|
||||
fuzzDSLDefaults = []string{`to_upper(hello)`, `contains(to_lower(all_headers), "server")`, `body`, `content_type`}
|
||||
fuzzRegexCorpus = "token=abc123\nserver=nginx\nurl=https://example.com/path\nhello=world"
|
||||
fuzzJSONCorpus = `{"id":1,"name":"nuclei","items":[{"id":"a1"},{"id":"b2"}],"headers":{"server":"nginx"},"links":[{"href":"https://example.com"}]}`
|
||||
fuzzHTMLCorpus = `<!doctype html><html><head><title>Example Domain</title><meta content="text/html" name="description" /></head><body><div><p>hello</p><p><a href="https://www.iana.org/domains/example">More information...</a></p></div></body></html>`
|
||||
fuzzXMLCorpus = `<?xml version="1.0"?><root><item id="alpha">A</item><item id="beta">B</item><link href="https://example.com">Example</link></root>`
|
||||
)
|
||||
|
||||
type fuzzExtractorCandidate struct {
|
||||
extractorType ExtractorType
|
||||
part string
|
||||
name string
|
||||
attribute string
|
||||
internal bool
|
||||
caseInsensitive bool
|
||||
explicitCaseInsensitive bool
|
||||
regexGroup int
|
||||
values []string
|
||||
}
|
||||
|
||||
func extractorFromFuzzData(data []byte) (*Extractor, bool) {
|
||||
if len(data) == 0 || len(data) > fuzzMaxInputSize {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
candidate := newFuzzExtractorCandidate(data)
|
||||
candidate.applyLines(splitFuzzLines(data))
|
||||
candidate.addFallbackValues(data)
|
||||
|
||||
return candidate.build()
|
||||
}
|
||||
|
||||
func newFuzzExtractorCandidate(data []byte) *fuzzExtractorCandidate {
|
||||
flags := fuzzByteAt(data, 1)
|
||||
return &fuzzExtractorCandidate{
|
||||
extractorType: fuzzExtractorTypes[int(fuzzByteAt(data, 0))%len(fuzzExtractorTypes)],
|
||||
part: fuzzParts[int(fuzzByteAt(data, 2))%len(fuzzParts)],
|
||||
name: fuzzName(data),
|
||||
attribute: fuzzAttributes[int(fuzzByteAt(data, 3))%len(fuzzAttributes)],
|
||||
internal: flags&0x01 != 0,
|
||||
caseInsensitive: flags&0x02 != 0,
|
||||
regexGroup: int(fuzzByteAt(data, 4) % 3),
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzExtractorCandidate) applyLines(lines []string) {
|
||||
for _, line := range lines {
|
||||
key, rawValue, ok := cutFuzzKV(line)
|
||||
if !ok {
|
||||
candidate.addValue(line)
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "type":
|
||||
extractorType, err := toExtractorTypes(rawValue)
|
||||
if err != nil {
|
||||
candidate.extractorType = ExtractorType(0)
|
||||
} else {
|
||||
candidate.extractorType = extractorType
|
||||
}
|
||||
case "part":
|
||||
candidate.part = trimFuzzValue(rawValue)
|
||||
case "name":
|
||||
candidate.name = fuzzNameFromText(rawValue)
|
||||
case "attribute":
|
||||
candidate.attribute = fuzzAttribute(rawValue)
|
||||
case "internal":
|
||||
candidate.internal = parseFuzzBool(rawValue, candidate.internal)
|
||||
case "case-insensitive":
|
||||
candidate.caseInsensitive = parseFuzzBool(rawValue, candidate.caseInsensitive)
|
||||
candidate.explicitCaseInsensitive = true
|
||||
case "group":
|
||||
candidate.regexGroup = parseFuzzGroup(rawValue, candidate.regexGroup)
|
||||
case "value":
|
||||
candidate.addValue(rawValue)
|
||||
case "regex":
|
||||
candidate.extractorType = RegexExtractor
|
||||
candidate.addValue(rawValue)
|
||||
case "kval":
|
||||
candidate.extractorType = KValExtractor
|
||||
candidate.addValue(rawValue)
|
||||
case "json":
|
||||
candidate.extractorType = JSONExtractor
|
||||
candidate.addValue(rawValue)
|
||||
case "xpath":
|
||||
candidate.extractorType = XPathExtractor
|
||||
candidate.addValue(rawValue)
|
||||
case "dsl":
|
||||
candidate.extractorType = DSLExtractor
|
||||
candidate.addValue(rawValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzExtractorCandidate) addFallbackValues(payload []byte) {
|
||||
if len(candidate.values) > 0 || len(candidate.values) >= fuzzMaxItems {
|
||||
return
|
||||
}
|
||||
|
||||
fields := splitFuzzFields(payload)
|
||||
switch candidate.extractorType {
|
||||
case RegexExtractor:
|
||||
for _, field := range fields {
|
||||
candidate.addValue(fuzzRegexValue(field))
|
||||
}
|
||||
candidate.addDefaults(fuzzRegexDefaults, fuzzByteAt(payload, 5))
|
||||
case KValExtractor:
|
||||
for _, field := range fields {
|
||||
candidate.addValue(fuzzIdentifier(field))
|
||||
}
|
||||
candidate.addDefaults(fuzzKValDefaults, fuzzByteAt(payload, 5))
|
||||
case JSONExtractor:
|
||||
for _, field := range fields {
|
||||
candidate.addValue(fuzzJSONQuery(field))
|
||||
}
|
||||
candidate.addDefaults(fuzzJSONDefaults, fuzzByteAt(payload, 5))
|
||||
case XPathExtractor:
|
||||
for _, field := range fields {
|
||||
candidate.addValue(fuzzXPathQuery(field))
|
||||
}
|
||||
candidate.addDefaults(fuzzXPathDefaults, fuzzByteAt(payload, 5))
|
||||
case DSLExtractor:
|
||||
for _, field := range fields {
|
||||
candidate.addValue(fuzzDSLExpression(field))
|
||||
}
|
||||
candidate.addDefaults(fuzzDSLDefaults, fuzzByteAt(payload, 5))
|
||||
default:
|
||||
candidate.addDefaults(fuzzRegexDefaults, fuzzByteAt(payload, 5))
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzExtractorCandidate) addDefaults(defaults []string, seed byte) {
|
||||
if len(candidate.values) >= fuzzMaxItems || len(defaults) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
start := int(seed) % len(defaults)
|
||||
for offset := 0; offset < len(defaults) && len(candidate.values) < 2; offset++ {
|
||||
candidate.addValue(defaults[(start+offset)%len(defaults)])
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzExtractorCandidate) addValue(value string) {
|
||||
value = trimFuzzValue(value)
|
||||
if value == "" || len(candidate.values) >= fuzzMaxItems {
|
||||
return
|
||||
}
|
||||
for _, existing := range candidate.values {
|
||||
if existing == value {
|
||||
return
|
||||
}
|
||||
}
|
||||
candidate.values = append(candidate.values, value)
|
||||
}
|
||||
|
||||
func (candidate *fuzzExtractorCandidate) build() (*Extractor, bool) {
|
||||
extractor := &Extractor{
|
||||
Type: ExtractorTypeHolder{ExtractorType: candidate.extractorType},
|
||||
Name: candidate.name,
|
||||
Part: candidate.part,
|
||||
Internal: candidate.internal,
|
||||
Attribute: candidate.attribute,
|
||||
RegexGroup: candidate.regexGroup,
|
||||
}
|
||||
|
||||
if candidate.extractorType == KValExtractor || candidate.explicitCaseInsensitive {
|
||||
extractor.CaseInsensitive = candidate.caseInsensitive
|
||||
}
|
||||
if candidate.extractorType != XPathExtractor {
|
||||
extractor.Attribute = ""
|
||||
}
|
||||
if candidate.extractorType != RegexExtractor {
|
||||
extractor.RegexGroup = 0
|
||||
}
|
||||
|
||||
switch candidate.extractorType {
|
||||
case RegexExtractor:
|
||||
extractor.Regex = append([]string(nil), candidate.values...)
|
||||
case KValExtractor:
|
||||
extractor.KVal = append([]string(nil), candidate.values...)
|
||||
case XPathExtractor:
|
||||
extractor.XPath = append([]string(nil), candidate.values...)
|
||||
case JSONExtractor:
|
||||
extractor.JSON = append([]string(nil), candidate.values...)
|
||||
case DSLExtractor:
|
||||
extractor.DSL = append([]string(nil), candidate.values...)
|
||||
default:
|
||||
extractor.Regex = append([]string(nil), candidate.values...)
|
||||
}
|
||||
|
||||
return extractor, len(candidate.values) > 0
|
||||
}
|
||||
|
||||
func exerciseFuzzExtractor(extractor *Extractor) {
|
||||
switch extractor.GetType() {
|
||||
case RegexExtractor:
|
||||
_ = extractor.ExtractRegex(fuzzRegexCorpus)
|
||||
case KValExtractor:
|
||||
_ = extractor.ExtractKval(fuzzKValData())
|
||||
case XPathExtractor:
|
||||
_ = extractor.ExtractXPath(fuzzHTMLCorpus)
|
||||
_ = extractor.ExtractXPath(fuzzXMLCorpus)
|
||||
case JSONExtractor:
|
||||
_ = extractor.ExtractJSON(fuzzJSONCorpus)
|
||||
case DSLExtractor:
|
||||
_ = extractor.ExtractDSL(fuzzDSLData())
|
||||
}
|
||||
}
|
||||
|
||||
func fuzzKValData() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"content_type": "Text/HTML",
|
||||
"server": "Nginx",
|
||||
"set_cookie": "session=abc123",
|
||||
"x_powered_by": "Go",
|
||||
}
|
||||
}
|
||||
|
||||
func fuzzDSLData() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"hello": "hi",
|
||||
"body": "PING PONG",
|
||||
"all_headers": "Server: Example\nContent-Type: text/html",
|
||||
"content_type": "text/html",
|
||||
"status_code": 200,
|
||||
}
|
||||
}
|
||||
|
||||
func splitFuzzLines(data []byte) []string {
|
||||
fields := strings.FieldsFunc(string(data), func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == ';'
|
||||
})
|
||||
if len(fields) > fuzzMaxItems*4 {
|
||||
fields = fields[:fuzzMaxItems*4]
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = trimFuzzValue(field)
|
||||
if field != "" {
|
||||
lines = append(lines, field)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func splitFuzzFields(data []byte) []string {
|
||||
fields := strings.FieldsFunc(string(data), func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == '|' || r == ','
|
||||
})
|
||||
if len(fields) > fuzzMaxItems {
|
||||
fields = fields[:fuzzMaxItems]
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = trimFuzzValue(field)
|
||||
if field != "" {
|
||||
values = append(values, field)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func cutFuzzKV(line string) (string, string, bool) {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
key, value, ok = strings.Cut(line, ":")
|
||||
}
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(key)), trimFuzzValue(value), true
|
||||
}
|
||||
|
||||
func trimFuzzValue(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
|
||||
if len(value) > fuzzMaxValueBytes {
|
||||
value = value[:fuzzMaxValueBytes]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fuzzName(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(data) > 8 {
|
||||
data = data[:8]
|
||||
}
|
||||
return "fuzz-" + strconv.FormatUint(uint64(data[0]), 16) + fuzzNameSuffix(data[1:])
|
||||
}
|
||||
|
||||
func fuzzNameSuffix(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, value := range data {
|
||||
if builder.Len() >= 15 {
|
||||
break
|
||||
}
|
||||
builder.WriteString(strconv.FormatUint(uint64(value), 16))
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func fuzzNameFromText(value string) string {
|
||||
value = strings.ToLower(trimFuzzValue(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
case r == '-':
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
if builder.Len() >= 32 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func fuzzAttribute(value string) string {
|
||||
attribute := fuzzIdentifier(value)
|
||||
if attribute == "" {
|
||||
return trimFuzzValue(value)
|
||||
}
|
||||
return attribute
|
||||
}
|
||||
|
||||
func parseFuzzBool(value string, fallback bool) bool {
|
||||
switch strings.ToLower(trimFuzzValue(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func parseFuzzGroup(value string, fallback int) int {
|
||||
number, err := strconv.Atoi(trimFuzzValue(value))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
if number < -2 {
|
||||
return -2
|
||||
}
|
||||
if number > 8 {
|
||||
return 8
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
func fuzzRegexValue(value string) string {
|
||||
value = trimFuzzValue(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return regexp.QuoteMeta(value)
|
||||
}
|
||||
|
||||
func fuzzJSONQuery(value string) string {
|
||||
identifier := fuzzIdentifier(value)
|
||||
if identifier == "" {
|
||||
return ""
|
||||
}
|
||||
return "." + identifier
|
||||
}
|
||||
|
||||
func fuzzXPathQuery(value string) string {
|
||||
identifier := fuzzIdentifier(value)
|
||||
if identifier == "" {
|
||||
return ""
|
||||
}
|
||||
return "//" + identifier
|
||||
}
|
||||
|
||||
func fuzzDSLExpression(value string) string {
|
||||
identifier := fuzzIdentifier(value)
|
||||
if identifier == "" {
|
||||
return ""
|
||||
}
|
||||
switch identifier {
|
||||
case "hello", "body", "all_headers", "content_type":
|
||||
return "to_upper(" + identifier + ")"
|
||||
case "status_code":
|
||||
return identifier
|
||||
default:
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
func fuzzIdentifier(value string) string {
|
||||
value = strings.ToLower(trimFuzzValue(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
case r >= '0' && r <= '9':
|
||||
if builder.Len() == 0 {
|
||||
builder.WriteString("field_")
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
case r == '_' || r == '-' || r == '.' || r == ' ':
|
||||
if builder.Len() > 0 && !lastUnderscore {
|
||||
builder.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
if builder.Len() >= 32 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Trim(builder.String(), "_")
|
||||
}
|
||||
|
||||
func fuzzByteAt(data []byte, index int) byte {
|
||||
if index < 0 || index >= len(data) {
|
||||
return 0
|
||||
}
|
||||
return data[index]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package extractors
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractorFromFuzzDataSeedCorpus(t *testing.T) {
|
||||
entries, err := os.ReadDir("testdata/gofuzz-corpus")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join("testdata/gofuzz-corpus", entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
extractor, ok := extractorFromFuzzData(data)
|
||||
require.Truef(t, ok, "seed %s should decode into an extractor", entry.Name())
|
||||
require.NoErrorf(t, extractor.CompileExtractors(), "seed %s should compile", entry.Name())
|
||||
require.NotPanicsf(t, func() {
|
||||
exerciseFuzzExtractor(extractor)
|
||||
}, "seed %s should execute extraction paths", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorFromFuzzDataRejectsOversizeInput(t *testing.T) {
|
||||
data := make([]byte, fuzzMaxInputSize+1)
|
||||
extractor, ok := extractorFromFuzzData(data)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, extractor)
|
||||
}
|
||||
|
||||
func TestExtractorFromFuzzDataFallbacksCompile(t *testing.T) {
|
||||
testcases := []string{
|
||||
"type=regex",
|
||||
"type=kval\ncase-insensitive=true",
|
||||
"type=json",
|
||||
"type=xpath",
|
||||
"type=dsl",
|
||||
}
|
||||
|
||||
for _, testcase := range testcases {
|
||||
extractor, ok := extractorFromFuzzData([]byte(testcase))
|
||||
require.Truef(t, ok, "fallback input %q should decode", testcase)
|
||||
require.NoErrorf(t, extractor.CompileExtractors(), "fallback input %q should compile", testcase)
|
||||
require.NotPanicsf(t, func() {
|
||||
exerciseFuzzExtractor(extractor)
|
||||
}, "fallback input %q should execute extraction paths", testcase)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
type=dsl
|
||||
value=to_upper(hello)
|
||||
value=contains(to_lower(all_headers), "server")
|
||||
@@ -0,0 +1,3 @@
|
||||
type=json
|
||||
value=.items[] | .id
|
||||
value=.headers.server
|
||||
@@ -0,0 +1,4 @@
|
||||
type=kval
|
||||
case-insensitive=true
|
||||
value=content_type
|
||||
value=server
|
||||
@@ -0,0 +1,4 @@
|
||||
type=regex
|
||||
group=1
|
||||
value=token=([a-z0-9]+)
|
||||
value=https?://([^\s"']+)
|
||||
@@ -0,0 +1,4 @@
|
||||
type=xpath
|
||||
attribute=href
|
||||
value=/html/body/div/p[2]/a
|
||||
value=//link
|
||||
@@ -0,0 +1,3 @@
|
||||
type=xpath
|
||||
value=//title
|
||||
value=/root/item
|
||||
@@ -0,0 +1,29 @@
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package matchers
|
||||
|
||||
import "github.com/projectdiscovery/nuclei/v3/pkg/operators/cache"
|
||||
|
||||
func init() {
|
||||
cache.SetCapacities(128, 128)
|
||||
}
|
||||
|
||||
// Fuzz exercises matcher compilation with a compact line-based grammar.
|
||||
func Fuzz(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
if len(data) > fuzzMaxInputSize {
|
||||
return -1
|
||||
}
|
||||
|
||||
matcher, ok := matcherFromFuzzData(data)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if err := matcher.CompileMatchers(); err != nil {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package matchers
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
fuzzMaxInputSize = 16 << 10
|
||||
fuzzMaxItems = 8
|
||||
fuzzMaxValueBytes = 256
|
||||
)
|
||||
|
||||
var (
|
||||
fuzzMatcherTypes = []MatcherType{WordsMatcher, RegexMatcher, BinaryMatcher, StatusMatcher, SizeMatcher, DSLMatcher, XPathMatcher}
|
||||
fuzzConditions = []string{"", "and", "or"}
|
||||
fuzzEncodings = []string{"", "hex"}
|
||||
fuzzParts = []string{"", "body", "raw", "all_headers", "header", "response"}
|
||||
)
|
||||
|
||||
type fuzzMatcherCandidate struct {
|
||||
matcherType MatcherType
|
||||
condition string
|
||||
part string
|
||||
encoding string
|
||||
name string
|
||||
negative bool
|
||||
caseInsensitive bool
|
||||
matchAll bool
|
||||
values []string
|
||||
status []int
|
||||
size []int
|
||||
}
|
||||
|
||||
func matcherFromFuzzData(data []byte) (*Matcher, bool) {
|
||||
if len(data) == 0 || len(data) > fuzzMaxInputSize {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
payload := data
|
||||
|
||||
candidate := newFuzzMatcherCandidate(data)
|
||||
candidate.applyLines(splitFuzzLines(payload))
|
||||
candidate.addFallbackValues(payload)
|
||||
candidate.addFallbackIntegers(payload)
|
||||
|
||||
return candidate.build()
|
||||
}
|
||||
|
||||
func newFuzzMatcherCandidate(data []byte) *fuzzMatcherCandidate {
|
||||
flags := fuzzByteAt(data, 1)
|
||||
return &fuzzMatcherCandidate{
|
||||
matcherType: fuzzMatcherTypes[int(fuzzByteAt(data, 0))%len(fuzzMatcherTypes)],
|
||||
condition: fuzzConditions[int(fuzzByteAt(data, 1))%len(fuzzConditions)],
|
||||
part: fuzzParts[int(fuzzByteAt(data, 2))%len(fuzzParts)],
|
||||
encoding: fuzzEncodings[int(fuzzByteAt(data, 3))%len(fuzzEncodings)],
|
||||
name: fuzzName(data),
|
||||
negative: flags&0x01 != 0,
|
||||
caseInsensitive: flags&0x02 != 0,
|
||||
matchAll: flags&0x04 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzMatcherCandidate) applyLines(lines []string) {
|
||||
for _, line := range lines {
|
||||
key, rawValue, ok := cutFuzzKV(line)
|
||||
if !ok {
|
||||
candidate.addValue(line)
|
||||
continue
|
||||
}
|
||||
|
||||
switch key {
|
||||
case "type":
|
||||
matcherType, err := toMatcherTypes(rawValue)
|
||||
if err != nil {
|
||||
candidate.matcherType = MatcherType(0)
|
||||
} else {
|
||||
candidate.matcherType = matcherType
|
||||
}
|
||||
case "condition":
|
||||
candidate.condition = trimFuzzValue(rawValue)
|
||||
case "part":
|
||||
candidate.part = trimFuzzValue(rawValue)
|
||||
case "encoding":
|
||||
candidate.encoding = trimFuzzValue(rawValue)
|
||||
case "name":
|
||||
candidate.name = fuzzNameFromText(rawValue)
|
||||
case "negative":
|
||||
candidate.negative = parseFuzzBool(rawValue, candidate.negative)
|
||||
case "case-insensitive":
|
||||
candidate.caseInsensitive = parseFuzzBool(rawValue, candidate.caseInsensitive)
|
||||
case "match-all":
|
||||
candidate.matchAll = parseFuzzBool(rawValue, candidate.matchAll)
|
||||
case "value":
|
||||
candidate.addValue(rawValue)
|
||||
case "word":
|
||||
candidate.matcherType = WordsMatcher
|
||||
candidate.addValue(rawValue)
|
||||
case "regex":
|
||||
candidate.matcherType = RegexMatcher
|
||||
candidate.addValue(rawValue)
|
||||
case "binary":
|
||||
candidate.matcherType = BinaryMatcher
|
||||
candidate.addValue(rawValue)
|
||||
case "dsl":
|
||||
candidate.matcherType = DSLMatcher
|
||||
candidate.addValue(rawValue)
|
||||
case "xpath":
|
||||
candidate.matcherType = XPathMatcher
|
||||
candidate.addValue(rawValue)
|
||||
case "status":
|
||||
candidate.matcherType = StatusMatcher
|
||||
candidate.status = append(candidate.status, fuzzParseStatuses(rawValue)...)
|
||||
case "size":
|
||||
candidate.matcherType = SizeMatcher
|
||||
candidate.size = append(candidate.size, fuzzParseSizes(rawValue)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzMatcherCandidate) addFallbackValues(payload []byte) {
|
||||
if len(candidate.values) > 0 || len(candidate.values) >= fuzzMaxItems {
|
||||
return
|
||||
}
|
||||
|
||||
for _, value := range splitFuzzFields(payload) {
|
||||
candidate.addValue(value)
|
||||
if len(candidate.values) >= fuzzMaxItems {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzMatcherCandidate) addFallbackIntegers(payload []byte) {
|
||||
if len(candidate.status) == 0 {
|
||||
candidate.status = append(candidate.status, fuzzStatusValue(int(fuzzByteAt(payload, 0))))
|
||||
candidate.status = append(candidate.status, fuzzStatusValue(int(fuzzByteAt(payload, 1))))
|
||||
}
|
||||
if len(candidate.size) == 0 {
|
||||
candidate.size = append(candidate.size, fuzzSizeValue(int(fuzzByteAt(payload, 2))|(int(fuzzByteAt(payload, 3))<<8)))
|
||||
candidate.size = append(candidate.size, fuzzSizeValue(int(fuzzByteAt(payload, 4))|(int(fuzzByteAt(payload, 5))<<8)))
|
||||
}
|
||||
}
|
||||
|
||||
func (candidate *fuzzMatcherCandidate) addValue(value string) {
|
||||
value = trimFuzzValue(value)
|
||||
if value == "" || len(candidate.values) >= fuzzMaxItems {
|
||||
return
|
||||
}
|
||||
candidate.values = append(candidate.values, value)
|
||||
}
|
||||
|
||||
func (candidate *fuzzMatcherCandidate) build() (*Matcher, bool) {
|
||||
matcher := &Matcher{
|
||||
Type: MatcherTypeHolder{MatcherType: candidate.matcherType},
|
||||
Condition: candidate.condition,
|
||||
Part: candidate.part,
|
||||
Negative: candidate.negative,
|
||||
Name: candidate.name,
|
||||
Encoding: candidate.encoding,
|
||||
CaseInsensitive: candidate.caseInsensitive,
|
||||
MatchAll: candidate.matchAll,
|
||||
}
|
||||
|
||||
switch candidate.matcherType {
|
||||
case DSLMatcher:
|
||||
matcher.Part = ""
|
||||
matcher.Encoding = ""
|
||||
matcher.CaseInsensitive = false
|
||||
case StatusMatcher, SizeMatcher:
|
||||
matcher.Encoding = ""
|
||||
matcher.CaseInsensitive = false
|
||||
case XPathMatcher:
|
||||
matcher.Encoding = ""
|
||||
matcher.CaseInsensitive = false
|
||||
case RegexMatcher, BinaryMatcher:
|
||||
matcher.CaseInsensitive = false
|
||||
}
|
||||
|
||||
switch candidate.matcherType {
|
||||
case WordsMatcher:
|
||||
matcher.Words = append([]string(nil), candidate.values...)
|
||||
case RegexMatcher:
|
||||
matcher.Regex = append([]string(nil), candidate.values...)
|
||||
case BinaryMatcher:
|
||||
matcher.Binary = append([]string(nil), candidate.values...)
|
||||
case DSLMatcher:
|
||||
matcher.DSL = append([]string(nil), candidate.values...)
|
||||
case XPathMatcher:
|
||||
matcher.XPath = append([]string(nil), candidate.values...)
|
||||
case StatusMatcher:
|
||||
matcher.Status = append([]int(nil), candidate.status...)
|
||||
case SizeMatcher:
|
||||
matcher.Size = append([]int(nil), candidate.size...)
|
||||
default:
|
||||
matcher.Words = append([]string(nil), candidate.values...)
|
||||
}
|
||||
|
||||
if matcher.GetType() == StatusMatcher || matcher.GetType() == SizeMatcher {
|
||||
return matcher, len(matcher.Status) > 0 || len(matcher.Size) > 0
|
||||
}
|
||||
return matcher, len(candidate.values) > 0
|
||||
}
|
||||
|
||||
func splitFuzzLines(data []byte) []string {
|
||||
fields := strings.FieldsFunc(string(data), func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == ';'
|
||||
})
|
||||
if len(fields) > fuzzMaxItems*4 {
|
||||
fields = fields[:fuzzMaxItems*4]
|
||||
}
|
||||
|
||||
lines := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = trimFuzzValue(field)
|
||||
if field != "" {
|
||||
lines = append(lines, field)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func splitFuzzFields(data []byte) []string {
|
||||
fields := strings.FieldsFunc(string(data), func(r rune) bool {
|
||||
return r == '\n' || r == '\r' || r == '|' || r == ','
|
||||
})
|
||||
if len(fields) > fuzzMaxItems {
|
||||
fields = fields[:fuzzMaxItems]
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
field = trimFuzzValue(field)
|
||||
if field != "" {
|
||||
values = append(values, field)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func cutFuzzKV(line string) (string, string, bool) {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
key, value, ok = strings.Cut(line, ":")
|
||||
}
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(key)), trimFuzzValue(value), true
|
||||
}
|
||||
|
||||
func trimFuzzValue(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", ""))
|
||||
if len(value) > fuzzMaxValueBytes {
|
||||
value = value[:fuzzMaxValueBytes]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func fuzzName(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(data) > 8 {
|
||||
data = data[:8]
|
||||
}
|
||||
return "fuzz-" + hex.EncodeToString(data)
|
||||
}
|
||||
|
||||
func fuzzNameFromText(value string) string {
|
||||
value = strings.ToLower(trimFuzzValue(value))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
case r == '-':
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
if builder.Len() >= 32 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if builder.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func parseFuzzBool(value string, fallback bool) bool {
|
||||
switch strings.ToLower(trimFuzzValue(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func fuzzParseStatuses(value string) []int {
|
||||
parsed := fuzzParseNumbers(value)
|
||||
statuses := make([]int, 0, len(parsed))
|
||||
for _, number := range parsed {
|
||||
statuses = append(statuses, fuzzStatusValue(number))
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func fuzzParseSizes(value string) []int {
|
||||
parsed := fuzzParseNumbers(value)
|
||||
sizes := make([]int, 0, len(parsed))
|
||||
for _, number := range parsed {
|
||||
sizes = append(sizes, fuzzSizeValue(number))
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
|
||||
func fuzzParseNumbers(value string) []int {
|
||||
tokens := strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == '|' || r == ' ' || r == '\t'
|
||||
})
|
||||
if len(tokens) > fuzzMaxItems {
|
||||
tokens = tokens[:fuzzMaxItems]
|
||||
}
|
||||
|
||||
parsed := make([]int, 0, len(tokens))
|
||||
for _, token := range tokens {
|
||||
token = trimFuzzValue(token)
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
number, err := strconv.Atoi(token)
|
||||
if err != nil {
|
||||
number = 0
|
||||
for i := 0; i < len(token); i++ {
|
||||
number += int(token[i])
|
||||
}
|
||||
}
|
||||
parsed = append(parsed, number)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func fuzzStatusValue(number int) int {
|
||||
if number >= 100 && number <= 599 {
|
||||
return number
|
||||
}
|
||||
if number < 0 {
|
||||
number = -number
|
||||
}
|
||||
return 100 + (number % 500)
|
||||
}
|
||||
|
||||
func fuzzSizeValue(number int) int {
|
||||
if number > 0 && number <= 1<<20 {
|
||||
return number
|
||||
}
|
||||
if number < 0 {
|
||||
number = -number
|
||||
}
|
||||
return 1 + (number % (1 << 16))
|
||||
}
|
||||
|
||||
func fuzzByteAt(data []byte, index int) byte {
|
||||
if index < 0 || index >= len(data) {
|
||||
return 0
|
||||
}
|
||||
return data[index]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package matchers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatcherFromFuzzDataSeedCorpus(t *testing.T) {
|
||||
entries, err := os.ReadDir("testdata/gofuzz-corpus")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, entries)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join("testdata/gofuzz-corpus", entry.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
matcher, ok := matcherFromFuzzData(data)
|
||||
require.Truef(t, ok, "seed %s should decode into a matcher", entry.Name())
|
||||
require.NoErrorf(t, matcher.CompileMatchers(), "seed %s should compile", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatcherFromFuzzDataRejectsOversizeInput(t *testing.T) {
|
||||
data := make([]byte, fuzzMaxInputSize+1)
|
||||
matcher, ok := matcherFromFuzzData(data)
|
||||
require.False(t, ok)
|
||||
require.Nil(t, matcher)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
type=binary
|
||||
condition=and
|
||||
part=raw
|
||||
value=50494e47
|
||||
value=414243
|
||||
@@ -0,0 +1,4 @@
|
||||
type=dsl
|
||||
condition=or
|
||||
value=contains(body, "PING")
|
||||
value=contains(tolower(all_headers), "server")
|
||||
@@ -0,0 +1,5 @@
|
||||
type=regex
|
||||
condition=or
|
||||
part=body
|
||||
value=abc[0-9]+
|
||||
value=(?i)ping
|
||||
@@ -0,0 +1,3 @@
|
||||
type=size
|
||||
part=body
|
||||
size=5,10,42
|
||||
@@ -0,0 +1,3 @@
|
||||
type=status
|
||||
part=body
|
||||
status=200,302
|
||||
@@ -0,0 +1,5 @@
|
||||
type=word
|
||||
encoding=hex
|
||||
part=body
|
||||
value=50494e47
|
||||
value=504f4e47
|
||||
@@ -0,0 +1,5 @@
|
||||
type=xpath
|
||||
condition=and
|
||||
part=body
|
||||
value=/html/body/div/p[2]/a
|
||||
value=//title[contains(text(), "Example")]
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build (linux || darwin || windows) && (amd64 || arm64)
|
||||
//go:build !gofuzz && (linux || darwin || windows) && (amd64 || arm64)
|
||||
|
||||
package json
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !(linux || darwin || windows) || !(amd64 || arm64)
|
||||
//go:build gofuzz || !(linux || darwin || windows) || !(amd64 || arm64)
|
||||
|
||||
package json
|
||||
|
||||
|
||||
Reference in new issue
Block a user