diff --git a/.github/workflows/fuzz.yaml b/.github/workflows/fuzz.yaml new file mode 100644 index 0000000..40bf097 --- /dev/null +++ b/.github/workflows/fuzz.yaml @@ -0,0 +1,48 @@ +name: Fuzz + +permissions: + contents: read + +on: + schedule: + - cron: "0 4 * * 1" + workflow_dispatch: + inputs: + fuzztime: + description: "Time budget per fuzz target (Go duration, e.g. 5m, 30m)" + required: false + default: "5m" + +jobs: + fuzz: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: fieldpath + package: ./internal/fieldpath/... + target: FuzzResolve + - name: webhook + package: ./internal/webhook/... + target: FuzzObjectFromRequest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + - name: Run fuzz + env: + FUZZTIME: ${{ inputs.fuzztime || '5m' }} + PACKAGE: ${{ matrix.package }} + TARGET: ${{ matrix.target }} + run: | + go test -run='^$' -fuzz="^${TARGET}$" -fuzztime="${FUZZTIME}" "${PACKAGE}" + - name: Upload discovered corpus on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-corpus-${{ matrix.name }} + path: | + **/testdata/fuzz/** + if-no-files-found: ignore diff --git a/internal/fieldpath/fieldpath_test.go b/internal/fieldpath/fieldpath_test.go index b2790ad..01dcdfb 100644 --- a/internal/fieldpath/fieldpath_test.go +++ b/internal/fieldpath/fieldpath_test.go @@ -46,3 +46,37 @@ func TestResolve_NilObj(t *testing.T) { t.Errorf("Resolve on nil obj = %q, want empty", got) } } + +func FuzzResolve(f *testing.F) { + for _, seed := range []string{ + ".spec.vpcRef.name", + "spec.vpcRef.name", + ".spec.missing.name", + ".spec.count", + ".spec.nested.deep", + ".nothing", + "", + ".", + "..", + ".spec..name", + } { + f.Add(seed) + } + + obj := map[string]any{ + "spec": map[string]any{ + "vpcRef": map[string]any{"name": "my-vpc"}, + "count": int64(3), + "nested": "not-a-map", + "list": []any{"a", "b"}, + "deep": map[string]any{ + "deeper": map[string]any{"deepest": "leaf"}, + }, + }, + } + + f.Fuzz(func(_ *testing.T, path string) { + _ = Resolve(obj, path) + _ = Resolve(nil, path) + }) +} diff --git a/internal/webhook/deletion_validator_test.go b/internal/webhook/deletion_validator_test.go index c3fac73..fb75f2e 100644 --- a/internal/webhook/deletion_validator_test.go +++ b/internal/webhook/deletion_validator_test.go @@ -120,3 +120,41 @@ func TestSkipProtection_WrongValue(t *testing.T) { t.Error("expected skip-protection to not match with value 'false'") } } + +func FuzzObjectFromRequest(f *testing.F) { + for _, seed := range [][]byte{ + nil, + []byte(``), + []byte(`{}`), + []byte(`null`), + []byte(`{"metadata":{"name":"foo"}}`), + []byte(`{"metadata":{"annotations":{"dependencies.opendefense.cloud/skip-protection":"true"}}}`), + []byte(`{"metadata":{"annotations":{"kcp.io/cluster":"root:org:workspace"}}}`), + []byte(`{"metadata":null}`), + []byte(`{"metadata":{"annotations":null}}`), + []byte(`[]`), + []byte(`"string-not-object"`), + []byte("{\"metadata\":{\"name\":\"\xff\xfe\"}}"), + {0x00, 0x01, 0x02}, + } { + f.Add(seed, true) + f.Add(seed, false) + } + + f.Fuzz(func(_ *testing.T, raw []byte, useOld bool) { + req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{}} + if useOld { + req.OldObject = runtime.RawExtension{Raw: raw} + } else { + req.Object = runtime.RawExtension{Raw: raw} + } + + obj, err := objectFromRequest(req) + if err != nil { + return + } + _ = obj.GetAnnotations()[AnnotationSkipProtection] + _ = obj.GetName() + _ = obj.GetNamespace() + }) +}