diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..1121636 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,41 @@ +name: "Fuzz" + +on: + pull_request: + branches: + - main + schedule: + # Weekly long-running fuzz pass on Wednesdays at 06:00 UTC. + - cron: "0 6 * * 3" + workflow_dispatch: + inputs: + fuzz_time: + description: "Per-target fuzz duration (e.g. 60s, 5m, 1h)" + required: false + default: "60s" + +permissions: + contents: read + +jobs: + fuzz: + name: Run Go fuzz targets + runs-on: ubuntu-latest + permissions: + contents: read + env: + # PR runs use a short budget so they don't block merges. + # The scheduled weekly run gets a longer budget to actually find bugs. + # workflow_dispatch lets a maintainer pick any duration. + FUZZ_TIME: ${{ inputs.fuzz_time || (github.event_name == 'schedule' && '10m' || '60s') }} + steps: + - name: Check out code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go 1.x + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: ./go.mod + + - name: make fuzz + run: make fuzz diff --git a/Makefile b/Makefile index 4dd6f89..73f97d3 100644 --- a/Makefile +++ b/Makefile @@ -167,6 +167,27 @@ test: $(PROJECT_COVERAGE_FILE) go-mod-tidy go-fmt go-vet go-generate ## Run test ./... \ ) +# Default duration each individual fuzz target runs for. Override on the +# command line, e.g. `FUZZ_TIME=5m make fuzz`. Go's -fuzz flag only accepts +# one target per invocation, so the loop below iterates over every Fuzz* +# function discovered by `go test -list`. +FUZZ_TIME ?= 60s + +.PHONY: fuzz +fuzz: ## Run every Go fuzz target for $$FUZZ_TIME each (default 60s) + @printf "👉 Running fuzz tests (FUZZ_TIME=$(FUZZ_TIME))...\n" + @set -e; \ + any=0; \ + for pkg in $$(go list ./... | grep -v /mocks/); do \ + fuzzes=$$(go test -list '^Fuzz' $$pkg 2>/dev/null | grep -E '^Fuzz' || true); \ + for fz in $$fuzzes; do \ + any=1; \ + printf " 🤞 %s :: %s for %s\n" $$pkg $$fz $(FUZZ_TIME); \ + go test -run='^$$' -fuzz="^$$fz$$" -fuzztime=$(FUZZ_TIME) $$pkg; \ + done; \ + done; \ + if [ $$any -eq 0 ]; then printf " ⚠️ no fuzz targets found\n"; fi + ############################################################################### ##@ Build commands .PHONY: build diff --git a/docs/Whats-New.md b/docs/Whats-New.md index 710b915..46088fc 100644 --- a/docs/Whats-New.md +++ b/docs/Whats-New.md @@ -4,6 +4,25 @@ This document tracks notable changes, new features, and bug fixes across release ## Unreleased +### OpenSSF Scorecard Hardening (Phase 4) — Fuzzing + +Closes the **Fuzzing** Scorecard check by adding native Go fuzz targets and a CI workflow that exercises them. + +**Targets added:** + +* `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). +* `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. + +**Tooling:** + +* New `make fuzz` target enumerates every `Fuzz*` function via `go test -list` and runs each one for `FUZZ_TIME` (default `60s`, override per-invocation). +* New `.github/workflows/fuzz.yml`: + * Runs every PR with a **60s budget per target** (fast enough to not block merges). + * Runs weekly on Wednesdays at 06:00 UTC with a **10-minute budget per target** to actually find bugs. + * `workflow_dispatch` lets a maintainer pick any duration on demand. + +When a fuzz target finds a crashing input, Go saves it under `testdata/fuzz//`. Commit those files: they become permanent regression tests that run as part of `go test ./...`. + ### OpenSSF Scorecard Hardening (Phase 3) — Signed releases + SLSA provenance Closes the **Signed-Releases** Scorecard check (0/10 → 10/10) by adopting two complementary supply-chain primitives: diff --git a/internal/model/group_fuzz_test.go b/internal/model/group_fuzz_test.go new file mode 100644 index 0000000..af0dbd8 --- /dev/null +++ b/internal/model/group_fuzz_test.go @@ -0,0 +1,32 @@ +package model + +import ( + "testing" +) + +// FuzzGroupsResultUnmarshalBinary fuzzes the gob deserialization of a +// GroupsResult. The decoder loops `Items` times calling dec.Decode, so an +// attacker who controls the encoded blob can ask for an enormous number of +// items and force the process to allocate aggressively before failing. The +// fuzzer should surface panics, hangs, or unbounded allocations. +func FuzzGroupsResultUnmarshalBinary(f *testing.F) { + // Seed with a valid round-tripped encoding. + seed := GroupsResult{ + Items: 2, + Resources: []*Group{ + {IPID: "1", Name: "a", Email: "a@example.com"}, + {IPID: "2", Name: "b", Email: "b@example.com"}, + }, + } + if data, err := seed.MarshalBinary(); err == nil { + f.Add(data) + } + f.Add([]byte{}) + f.Add([]byte{0x00}) + f.Add([]byte{0xff, 0xff, 0xff, 0xff}) + + f.Fuzz(func(t *testing.T, data []byte) { + var gr GroupsResult + _ = gr.UnmarshalBinary(data) + }) +} diff --git a/internal/repository/disk_fuzz_test.go b/internal/repository/disk_fuzz_test.go new file mode 100644 index 0000000..9f644e6 --- /dev/null +++ b/internal/repository/disk_fuzz_test.go @@ -0,0 +1,34 @@ +package repository + +import ( + "bytes" + "context" + "testing" +) + +// FuzzDiskRepositoryGetState fuzzes the state-file loading path. +// +// The state file is the primary untrusted-input surface in this project: +// users can persist it to disk, S3, or another external store and re-load +// it later. A malformed state file must never crash, panic, or hang the +// reconciliation loop — at worst it should return a typed error. +func FuzzDiskRepositoryGetState(f *testing.F) { + // Seed with well-formed and adversarial inputs so the fuzzer has + // useful starting points to mutate from. + f.Add([]byte(`{}`)) + f.Add([]byte(`{"schemaVersion":"1.0.0","codeVersion":"v0.0.0","hashCode":"abc","resources":{"groups":{},"users":{},"groupsMembers":{}}}`)) + f.Add([]byte(``)) + f.Add([]byte(`{"resources":{`)) + f.Add([]byte(`{"resources":{"groups":{"items":2147483647,"resources":null}}}`)) + f.Add([]byte("\x00\x01\x02\x03")) + + f.Fuzz(func(t *testing.T, data []byte) { + buf := bytes.NewBuffer(data) + dr, err := NewDiskRepository(buf) + if err != nil { + return + } + // Errors are acceptable; panics, OOMs, and hangs are not. + _, _ = dr.GetState(context.Background()) + }) +}