Skip to content

Commit 25dd64e

Browse files
christiangdaclaude
andcommitted
feat(test): add Go fuzz targets and CI workflow (OpenSSF Phase 4)
Closes the OpenSSF Scorecard Fuzzing check by adding native Go fuzz targets on the two highest-value untrusted-input surfaces in the project, plus tooling to exercise them locally and in CI. Fuzz targets: * internal/repository.FuzzDiskRepositoryGetState Fuzzes JSON state-file loading. State files can be persisted to disk or S3 and re-loaded later, so they are the primary untrusted-input surface in the project. A malformed file must never panic, OOM, or hang the reconciliation loop -- only return a typed error. * internal/model.FuzzGroupsResultUnmarshalBinary Fuzzes gob deserialization. The decoder loops `Items` times calling dec.Decode, so an attacker who controls the encoded blob can request an enormous number of items and force aggressive allocation. The fuzzer surfaces panics, hangs, or unbounded allocation here. Tooling: * New `make fuzz` target. Go's -fuzz only accepts one target per invocation, so the recipe iterates over every Fuzz* function found by `go test -list`. FUZZ_TIME (default 60s) is per-target. * New .github/workflows/fuzz.yml: - pull_request -> 60s/target (won't block merges) - schedule weekly Wed 06:00Z -> 10m/target (actually finds bugs) - workflow_dispatch lets a maintainer override the duration Smoke-tested both targets locally for 5s each: ~92-112k execs/sec, no crashes, 16-88 new interesting inputs discovered. `make test` still passes -- fuzz files compile as normal Go tests, the seed corpus runs as part of the unit-test pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 849f8d1 commit 25dd64e

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)