Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/Whats-New.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<FuzzName>/<hash>`. 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:
Expand Down
32 changes: 32 additions & 0 deletions internal/model/group_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
34 changes: 34 additions & 0 deletions internal/repository/disk_fuzz_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
Loading