test: add fuzz tests

Signed-off-by: Dwi Siswanto <[email protected]>
This commit is contained in:
Dwi Siswanto committed 2026-03-31 15:07:08 +07:00
1 parent 4ae87beedd
commit 35146e0a43
28 files changed
+1361 -8

No files matched your search

+54
View File
@@ -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
+4 -2
View File
@@ -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:
+1
View File
@@ -2,6 +2,7 @@
**/*-cache
**/*-config
**/.cache
**/.gofuzz/
**/*.DS_Store
**/*.exe
.devcontainer
+91
View File
@@ -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.
+121 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+30
View File
@@ -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
}
+463
View File
@@ -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
+29
View File
@@ -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
}
+376
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
//go:build (linux || darwin || windows) && (amd64 || arm64)
//go:build !gofuzz && (linux || darwin || windows) && (amd64 || arm64)
package json
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !(linux || darwin || windows) || !(amd64 || arm64)
//go:build gofuzz || !(linux || darwin || windows) || !(amd64 || arm64)
package json