diff --git a/CLAUDE.md b/CLAUDE.md index 611fff3..4500d71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,8 +52,9 @@ everything. | Lint Helm | `task lint:helm` | `dagger call lint-helm` | | Lint Markdown | `task lint:markdown` | `dagger call lint-markdown` | | Lint all | `task lint` | Go + Helm + Renovate + Markdown | -| All tests | `task test` | runs `test:unit` + `test:e2e` sequentially | +| All tests | `task test` | runs `test:unit` + `test:fuzz` + `test:e2e` sequentially | | Unit tests | `task test:unit` | `dagger call test` — gotestsum + `-race` + coverage | +| Fuzz smoke | `task test:fuzz` | each `Fuzz*` target run for 5s — catches seed-corpus regressions | | End-to-end tests | `task test:e2e` | throwaway k3d cluster, Helm install, scrape `/metrics` | | Vuln scan | `task security:govulncheck` | `dagger call govulncheck` | | Vuln scan (deps) | `task security:vuln-deps` | `dagger call trivy --scan-type=fs` | @@ -198,6 +199,11 @@ the pod's state or making HTTP requests to it right after modifying source files from `internal/` without a real consumer. - Tests use the standard `*_test.go` colocated layout. Prefer table-driven tests for the parser/registry packages. +- Parsers that consume untrusted bytes (PEM, PKCS#12, fileglob patterns) carry + `Fuzz*` targets in `*_fuzz_test.go` files. `task test:fuzz` runs each for 5s + (smoke); for a real session use `go test -fuzz= -fuzztime=10m ./`. + A crash gets persisted under `testdata/fuzz/...` and becomes a regression + test — commit those files alongside the fix. - Version metadata is injected at build time via `-ldflags -X` into `internal/product`. Don't read it from env or from disk at runtime. - The `formatVersion` short form is `MAJOR.MINOR.PATCH+gSHORTSHA` and gets a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 514143a..f6b1421 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -207,7 +207,7 @@ task --list # show every available task with description task build # `go build` directly (host-arch only) task image # `goreleaser release --snapshot ...` task lint # → go run ./dagger lint:go (+helm, +renovate) -task test # runs test:unit then test:e2e +task test # runs test:unit, test:fuzz, test:e2e ``` Whenever you're not sure what command to run, `task --list` is the answer. @@ -597,8 +597,9 @@ releases. | `task dev:up` | Tilt up — full dev loop | | `task dev:down` | Tilt down — keep cluster | | `task dev:cluster:down` | Destroy dev cluster + registry | -| `task test` | Run unit + e2e tests sequentially | +| `task test` | Run unit + fuzz smoke + e2e tests sequentially | | `task test:unit` | Unit tests with race detector + coverage (Dagger) | +| `task test:fuzz` | Smoke-run every `Fuzz*` target (5s each) | | `task test:e2e` | Full e2e against fresh throwaway cluster | | `task lint` | All linters (Go + Helm + Renovate) | | `task lint:{go,gocritic,gonocritic,helm,renovate}` | Single linter (Dagger) | diff --git a/Taskfile.yml b/Taskfile.yml index dcbcd32..0821770 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -189,6 +189,7 @@ tasks: desc: 'Run all tests (unit + e2e)' cmds: - task: test:unit + - task: test:fuzz - task: test:e2e test:unit: @@ -196,6 +197,16 @@ tasks: cmds: - dagger call --progress tty test + test:fuzz: + desc: 'Smoke-run every fuzzer (5s each) — fails on any new crash' + # Go fuzz only targets one Fuzz* function at a time, so we list each + # explicitly. Long-form fuzzing is a manual local activity: + # go test -fuzz= -fuzztime=10m ./ + cmds: + - go test -run=^$ -fuzz=FuzzParse -fuzztime=5s ./pkg/cert/pem/ + - go test -run=^$ -fuzz=FuzzParse -fuzztime=5s ./pkg/cert/pkcs12/ + - go test -run=^$ -fuzz=FuzzCompile -fuzztime=5s ./internal/fileglob/ + test:e2e: desc: 'End-to-end against a throwaway cluster — built and torn down by the task' vars: diff --git a/internal/fileglob/fileglob_fuzz_test.go b/internal/fileglob/fileglob_fuzz_test.go new file mode 100644 index 0000000..50d23e6 --- /dev/null +++ b/internal/fileglob/fileglob_fuzz_test.go @@ -0,0 +1,36 @@ +package fileglob + +import "testing" + +// FuzzCompile exercises the pattern compiler on arbitrary strings. +// Contract: Compile must never panic. Malformed character classes, +// unterminated brackets, weird metacharacters — all must return an error, +// never a crash. A successfully-compiled pattern must also Match without +// panic on arbitrary targets. +func FuzzCompile(f *testing.F) { + for _, seed := range []string{ + "", + "/etc/kubernetes/pki/*.crt", + "/var/**/*.pem", + "/etc/[abc]*.crt", + "/etc/[a-z]*.crt", + "[", // unterminated class + "[!]", // empty inverted class + "[z-a]", // inverted range + "**/**", // double recursive + "a\\b", // backslash + "{a,b}", // brace + "\xff\x00\x01", // binary garbage + } { + f.Add(seed) + } + + f.Fuzz(func(_ *testing.T, pattern string) { + p, err := Compile(pattern) + if err != nil { + return + } + _ = p.Match("/etc/kubernetes/pki/ca.crt") + _ = p.Match("") + }) +} diff --git a/pkg/cert/pem/pem_fuzz_test.go b/pkg/cert/pem/pem_fuzz_test.go new file mode 100644 index 0000000..91cf042 --- /dev/null +++ b/pkg/cert/pem/pem_fuzz_test.go @@ -0,0 +1,31 @@ +package pem + +import ( + "testing" + + "github.com/enix/x509-certificate-exporter/v4/pkg/cert" +) + +// FuzzParse exercises the PEM parser on arbitrary bytes. Contract: Parse +// must never panic, regardless of input. Truncated headers, garbage +// inside CERTIFICATE blocks, mixed block types — all must surface as +// Bundle.Errors entries, never as a runtime crash. +func FuzzParse(f *testing.F) { + for _, seed := range [][]byte{ + nil, + []byte("garbage"), + []byte("-----BEGIN CERTIFICATE-----\n"), + []byte("-----BEGIN CERTIFICATE-----\nQUFB\n-----END CERTIFICATE-----\n"), + []byte("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n"), + []byte("-----BEGIN TRUSTED CERTIFICATE-----\nQUFB\n-----END TRUSTED CERTIFICATE-----\n"), + []byte("-----BEGIN PRIVATE KEY-----\nQUFB\n-----END PRIVATE KEY-----\n"), + } { + f.Add(seed) + } + + p := New() + ref := cert.SourceRef{Kind: "fuzz", SourceName: "fuzz"} + f.Fuzz(func(_ *testing.T, data []byte) { + _ = p.Parse(data, ref, cert.ParseOptions{}) + }) +} diff --git a/pkg/cert/pkcs12/pkcs12_fuzz_test.go b/pkg/cert/pkcs12/pkcs12_fuzz_test.go new file mode 100644 index 0000000..140a406 --- /dev/null +++ b/pkg/cert/pkcs12/pkcs12_fuzz_test.go @@ -0,0 +1,36 @@ +package pkcs12 + +import ( + "testing" + + "github.com/enix/x509-certificate-exporter/v4/pkg/cert" +) + +// FuzzParse exercises the PKCS#12 parser on arbitrary bytes paired with +// arbitrary passphrases. Contract: Parse must never panic — corrupt ASN.1, +// wrong-length salt, malformed MAC, etc. must surface as Bundle.Errors, +// never as a runtime crash. +// +// PKCS#12 is a notoriously baroque format (PKCS#12 → PFX → PKCS#7 → +// PKCS#8 → ASN.1 DER), so the parser delegates to software.sslmate.com's +// implementation. This fuzzer enforces our invariant that even when that +// library returns an error or unexpected state, our wrapper stays sane. +func FuzzParse(f *testing.F) { + for _, data := range [][]byte{ + nil, + {0x30}, // bare ASN.1 SEQUENCE tag + {0x30, 0x00}, + []byte("not pkcs12"), + } { + for _, pass := range []string{"", "letmein"} { + f.Add(data, pass) + } + } + + p := New() + ref := cert.SourceRef{Kind: "fuzz", SourceName: "fuzz"} + f.Fuzz(func(_ *testing.T, data []byte, pass string) { + _ = p.Parse(data, ref, cert.ParseOptions{Pkcs12Passphrase: pass}) + _ = p.Parse(data, ref, cert.ParseOptions{Pkcs12Passphrase: pass, Pkcs12TryEmpty: true}) + }) +}