Skip to content

feat(test): add Go fuzz targets and CI workflow — OpenSSF Phase 4#532

Merged
christiangda merged 1 commit into
mainfrom
feat/openssf-fuzzing
May 23, 2026
Merged

feat(test): add Go fuzz targets and CI workflow — OpenSSF Phase 4#532
christiangda merged 1 commit into
mainfrom
feat/openssf-fuzzing

Conversation

@christiangda
Copy link
Copy Markdown
Contributor

@christiangda christiangda commented May 23, 2026

Summary

Phase 4 of the OpenSSF Scorecard hardening effort. Adds native Go fuzz targets on the two highest-value untrusted-input surfaces in the project, plus tooling to exercise them locally and in CI. Closes the Fuzzing check (0/10 → 10/10 once Scorecard re-evaluates).

Scorecard check Before Expected after
Fuzzing 0 / 10 10 / 10

Fuzz targets

internal/repository.FuzzDiskRepositoryGetState

Fuzzes JSON state-file loading via DiskRepository.GetState. 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.

Seed corpus includes well-formed state, empty input, truncated JSON, raw binary, and a "items": 2147483647 pathological case.

internal/model.FuzzGroupsResultUnmarshalBinary

Fuzzes gob deserialization via GroupsResult.UnmarshalBinary. 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 before failing. The fuzzer surfaces panics, hangs, or unbounded allocation here.

Seed corpus includes a valid round-trip encoding plus several adversarial byte patterns.

Tooling

make fuzz

Go's -fuzz flag only accepts one target per invocation, so the new Makefile recipe iterates over every Fuzz* function discovered by go test -list:

FUZZ_TIME=60s make fuzz   # default
FUZZ_TIME=5m  make fuzz   # longer
FUZZ_TIME=1h  make fuzz   # overnight

.github/workflows/fuzz.yml

Trigger Duration per target Rationale
pull_request to main 60s Quick — won't block merges
schedule (weekly, Wed 06:00 UTC) 10m Long enough to actually find bugs
workflow_dispatch configurable input Maintainer override

All actions SHA-pinned per project convention (actions/checkout@de0fac2e…, actions/setup-go@4a360112…).

Verification (local smoke test)

Both targets ran clean for 5 seconds each before commit:

internal/model :: FuzzGroupsResultUnmarshalBinary
  ~112k execs/sec, 88 new interesting inputs, PASS

internal/repository :: FuzzDiskRepositoryGetState
  ~92k execs/sec, 77 new interesting inputs, PASS

make test also passes — fuzz files compile as normal Go tests and their seed corpus runs as part of the unit-test pass.

What happens when the fuzzer finds a crash

Go writes the offending input to testdata/fuzz/<FuzzName>/<hash>. Commit those files: they become permanent regression tests that re-run as part of go test ./... going forward. No special tooling needed.

Test plan

  • Build workflow passes
  • CodeQL passes
  • New Fuzz workflow runs on this PR with the 60s/target budget
  • After merge: Scorecard re-evaluates and Fuzzing → 10/10

Follow-ups

  • PR-5: packaging detector (goreleaser) for the Packaging check
  • Optional later: apply to OSS-Fuzz for continuous fuzzing with massive compute budgets
  • Admin: branch protection on main, CII Silver application

🤖 Generated with Claude Code

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>
@christiangda christiangda self-assigned this May 23, 2026
@christiangda christiangda added this pull request to the merge queue May 23, 2026
Merged via the queue into main with commit bf36ac5 May 23, 2026
6 checks passed
@christiangda christiangda deleted the feat/openssf-fuzzing branch May 23, 2026 13:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant