Skip to content

Commit bf36ac5

Browse files
authored
Merge pull request #532 from slashdevops/feat/openssf-fuzzing
feat(test): add Go fuzz targets and CI workflow — OpenSSF Phase 4
2 parents 849f8d1 + 25dd64e commit bf36ac5

5 files changed

Lines changed: 147 additions & 0 deletions

File tree

.github/workflows/fuzz.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: "Fuzz"
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
schedule:
8+
# Weekly long-running fuzz pass on Wednesdays at 06:00 UTC.
9+
- cron: "0 6 * * 3"
10+
workflow_dispatch:
11+
inputs:
12+
fuzz_time:
13+
description: "Per-target fuzz duration (e.g. 60s, 5m, 1h)"
14+
required: false
15+
default: "60s"
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
fuzz:
22+
name: Run Go fuzz targets
23+
runs-on: ubuntu-latest
24+
permissions:
25+
contents: read
26+
env:
27+
# PR runs use a short budget so they don't block merges.
28+
# The scheduled weekly run gets a longer budget to actually find bugs.
29+
# workflow_dispatch lets a maintainer pick any duration.
30+
FUZZ_TIME: ${{ inputs.fuzz_time || (github.event_name == 'schedule' && '10m' || '60s') }}
31+
steps:
32+
- name: Check out code
33+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
34+
35+
- name: Set up Go 1.x
36+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
37+
with:
38+
go-version-file: ./go.mod
39+
40+
- name: make fuzz
41+
run: make fuzz

Makefile

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,27 @@ test: $(PROJECT_COVERAGE_FILE) go-mod-tidy go-fmt go-vet go-generate ## Run test
167167
./... \
168168
)
169169

170+
# Default duration each individual fuzz target runs for. Override on the
171+
# command line, e.g. `FUZZ_TIME=5m make fuzz`. Go's -fuzz flag only accepts
172+
# one target per invocation, so the loop below iterates over every Fuzz*
173+
# function discovered by `go test -list`.
174+
FUZZ_TIME ?= 60s
175+
176+
.PHONY: fuzz
177+
fuzz: ## Run every Go fuzz target for $$FUZZ_TIME each (default 60s)
178+
@printf "👉 Running fuzz tests (FUZZ_TIME=$(FUZZ_TIME))...\n"
179+
@set -e; \
180+
any=0; \
181+
for pkg in $$(go list ./... | grep -v /mocks/); do \
182+
fuzzes=$$(go test -list '^Fuzz' $$pkg 2>/dev/null | grep -E '^Fuzz' || true); \
183+
for fz in $$fuzzes; do \
184+
any=1; \
185+
printf " 🤞 %s :: %s for %s\n" $$pkg $$fz $(FUZZ_TIME); \
186+
go test -run='^$$' -fuzz="^$$fz$$" -fuzztime=$(FUZZ_TIME) $$pkg; \
187+
done; \
188+
done; \
189+
if [ $$any -eq 0 ]; then printf " ⚠️ no fuzz targets found\n"; fi
190+
170191
###############################################################################
171192
##@ Build commands
172193
.PHONY: build

docs/Whats-New.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ This document tracks notable changes, new features, and bug fixes across release
44

55
## Unreleased
66

7+
### OpenSSF Scorecard Hardening (Phase 4) — Fuzzing
8+
9+
Closes the **Fuzzing** Scorecard check by adding native Go fuzz targets and a CI workflow that exercises them.
10+
11+
**Targets added:**
12+
13+
* `internal/repository.FuzzDiskRepositoryGetState` — fuzzes JSON state-file loading, the primary untrusted-input surface in the project (state files can be persisted to disk or S3 and re-read).
14+
* `internal/model.FuzzGroupsResultUnmarshalBinary` — fuzzes gob deserialization, which loops `Items` times calling `dec.Decode`. An attacker who controls the encoded blob can request an enormous number of items; the fuzzer ensures this fails safely rather than panicking or hanging.
15+
16+
**Tooling:**
17+
18+
* New `make fuzz` target enumerates every `Fuzz*` function via `go test -list` and runs each one for `FUZZ_TIME` (default `60s`, override per-invocation).
19+
* New `.github/workflows/fuzz.yml`:
20+
* Runs every PR with a **60s budget per target** (fast enough to not block merges).
21+
* Runs weekly on Wednesdays at 06:00 UTC with a **10-minute budget per target** to actually find bugs.
22+
* `workflow_dispatch` lets a maintainer pick any duration on demand.
23+
24+
When a fuzz target finds a crashing input, Go saves it under `testdata/fuzz/<FuzzName>/<hash>`. Commit those files: they become permanent regression tests that run as part of `go test ./...`.
25+
726
### OpenSSF Scorecard Hardening (Phase 3) — Signed releases + SLSA provenance
827

928
Closes the **Signed-Releases** Scorecard check (0/10 → 10/10) by adopting two complementary supply-chain primitives:

internal/model/group_fuzz_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package model
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// FuzzGroupsResultUnmarshalBinary fuzzes the gob deserialization of a
8+
// GroupsResult. The decoder loops `Items` times calling dec.Decode, so an
9+
// attacker who controls the encoded blob can ask for an enormous number of
10+
// items and force the process to allocate aggressively before failing. The
11+
// fuzzer should surface panics, hangs, or unbounded allocations.
12+
func FuzzGroupsResultUnmarshalBinary(f *testing.F) {
13+
// Seed with a valid round-tripped encoding.
14+
seed := GroupsResult{
15+
Items: 2,
16+
Resources: []*Group{
17+
{IPID: "1", Name: "a", Email: "a@example.com"},
18+
{IPID: "2", Name: "b", Email: "b@example.com"},
19+
},
20+
}
21+
if data, err := seed.MarshalBinary(); err == nil {
22+
f.Add(data)
23+
}
24+
f.Add([]byte{})
25+
f.Add([]byte{0x00})
26+
f.Add([]byte{0xff, 0xff, 0xff, 0xff})
27+
28+
f.Fuzz(func(t *testing.T, data []byte) {
29+
var gr GroupsResult
30+
_ = gr.UnmarshalBinary(data)
31+
})
32+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package repository
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
)
8+
9+
// FuzzDiskRepositoryGetState fuzzes the state-file loading path.
10+
//
11+
// The state file is the primary untrusted-input surface in this project:
12+
// users can persist it to disk, S3, or another external store and re-load
13+
// it later. A malformed state file must never crash, panic, or hang the
14+
// reconciliation loop — at worst it should return a typed error.
15+
func FuzzDiskRepositoryGetState(f *testing.F) {
16+
// Seed with well-formed and adversarial inputs so the fuzzer has
17+
// useful starting points to mutate from.
18+
f.Add([]byte(`{}`))
19+
f.Add([]byte(`{"schemaVersion":"1.0.0","codeVersion":"v0.0.0","hashCode":"abc","resources":{"groups":{},"users":{},"groupsMembers":{}}}`))
20+
f.Add([]byte(``))
21+
f.Add([]byte(`{"resources":{`))
22+
f.Add([]byte(`{"resources":{"groups":{"items":2147483647,"resources":null}}}`))
23+
f.Add([]byte("\x00\x01\x02\x03"))
24+
25+
f.Fuzz(func(t *testing.T, data []byte) {
26+
buf := bytes.NewBuffer(data)
27+
dr, err := NewDiskRepository(buf)
28+
if err != nil {
29+
return
30+
}
31+
// Errors are acceptable; panics, OOMs, and hangs are not.
32+
_, _ = dr.GetState(context.Background())
33+
})
34+
}

0 commit comments

Comments
 (0)