From 106cde5939b3e915d792b0db4b233648194739c4 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Fri, 3 Jul 2026 03:37:17 -0700 Subject: [PATCH 1/2] Add native Go fuzzing coverage --- .github/workflows/fuzz.yml | 43 ++++++ CONTRIBUTING.md | 16 +++ Makefile | 6 +- dev-docs/CI.md | 19 +++ .../node/npm/npm_lockfile_fuzz_test.go | 62 +++++++++ .../node/pnpm/pnpm_lockfile_fuzz_test.go | 65 +++++++++ .../node/yarn/yarn_lockfile_fuzz_test.go | 65 +++++++++ internal/plugin/path_fuzz_test.go | 67 ++++++++++ internal/plugin/types.go | 3 + internal/sbom/codec_fuzz_test.go | 33 +++++ scripts/run-fuzz.sh | 22 +++ sdk/fuzz_test.go | 125 ++++++++++++++++++ 12 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 internal/detectors/node/npm/npm_lockfile_fuzz_test.go create mode 100644 internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go create mode 100644 internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go create mode 100644 internal/plugin/path_fuzz_test.go create mode 100644 internal/sbom/codec_fuzz_test.go create mode 100755 scripts/run-fuzz.sh create mode 100644 sdk/fuzz_test.go diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..bb9799db --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,43 @@ +name: Fuzz + +on: + workflow_dispatch: + schedule: + - cron: "0 8 * * *" + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: Native Go fuzzing + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Run native Go fuzz targets + run: make fuzz FUZZTIME=2m + + - name: Upload fuzz failures + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: go-fuzz-failures + path: | + **/testdata/fuzz/** + if-no-files-found: ignore + retention-days: 7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9109fc7..3042d797 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +96,7 @@ logger.Warn("cache miss", zap.Error(err)) - Add unit tests for new logic. - Add integration tests for new commands and user-visible flows. - Run `make test` before considering work complete. +- Run `make fuzz FUZZTIME=5s` after changing parsers, SBOM handling, plugin archive/path validation, or SDK transport JSON. Increase `FUZZTIME` for deeper local runs. - Run `make smoke` if you touched a detector chain, matcher, auditor, or analyzer. - If you change GitHub Actions workflows or release behavior, update [dev-docs/CI.md](dev-docs/CI.md) and any affected install guidance in [README.md](README.md). @@ -110,6 +111,21 @@ logger.Warn("cache miss", zap.Error(err)) Do not add skipped tests without a recorded reason. +### Fuzzing + +Native Go fuzz targets cover Bomly's highest-risk untrusted input boundaries. Run the committed fuzz target matrix with: + +```bash +make fuzz +make fuzz FUZZTIME=5s +``` + +When Go reports a minimized failure, rerun it with the command printed by `go test`, for example: + +```bash +go test ./internal/sbom -run=FuzzUnmarshalAutoJSON/ +``` + ## Documentation User-facing docs live in [`docs/`](docs/). Most pages are handwritten; some are generated. diff --git a/Makefile b/Makefile index 8bccddc8..b2a611a9 100644 --- a/Makefile +++ b/Makefile @@ -5,8 +5,9 @@ GO_LICENSES_VERSION=v1.6.0 GOPATH_BIN=$(shell go env GOPATH)/bin EXE_SUFFIX=$(if $(filter Windows_NT,$(OS)),.exe,) GOLANGCI_LINT=$(GOPATH_BIN)/golangci-lint$(EXE_SUFFIX) +FUZZTIME?=60s -.PHONY: build build-full build-lite fmt fmt-check lint install-hooks test run generate docs-config docs-schema docs-schema-md docs-support-matrix docs-components smoke benchmark benchmark-report licenses +.PHONY: build build-full build-lite fmt fmt-check lint install-hooks test fuzz run generate docs-config docs-schema docs-schema-md docs-support-matrix docs-components smoke benchmark benchmark-report licenses build: build-full build-lite @@ -34,6 +35,9 @@ install-hooks: test: go test ./... +fuzz: + FUZZTIME="$(FUZZTIME)" scripts/run-fuzz.sh + smoke: go test -tags "smoke" ./test/smoke/ -v -count=1 -timeout 15m $(if $(ARGS),$(ARGS),) diff --git a/dev-docs/CI.md b/dev-docs/CI.md index 8a64daf1..97f8248e 100644 --- a/dev-docs/CI.md +++ b/dev-docs/CI.md @@ -9,6 +9,7 @@ Bomly uses GitHub Actions for validation, security analysis, smoke coverage, and | `Build & Test` | Pull requests, pushes to `main` | Fast validation split into parallel jobs: `lint`, `test`, `build`, `format`, `modules` (go.mod drift), and `generated-docs` (generated-doc drift) | | `CodeQL` | Pull requests, pushes to `main`, weekly | Static security/quality analysis for Go; results surface in the Security tab | | `Scorecard` | Pushes to `main`, weekly, manual dispatch | OpenSSF Scorecard supply-chain checks; publishes results and uploads SARIF | +| `Fuzz` | Nightly schedule, manual dispatch | Native Go fuzzing over lockfile parsers, SBOM JSON, SDK transport JSON, and plugin path/archive sanitizers | | `Dependency review` | Pull requests | GitHub dependency-review of added/changed dependencies; fails on high-severity introductions | | `Bomly Guard` | Pull requests | Dogfoods the Bomly Guard action to diff and audit dependency changes on each PR | | `Smoke` | Merge queue, nightly schedule, manual dispatch | Slow end-to-end coverage against real repositories, SBOMs, and containers before merge, plus scheduled drift detection | @@ -102,6 +103,24 @@ Smoke tests use the pinned `ref`. The local benchmark intentionally does not, be The GitHub Actions `Smoke` workflow calls `make smoke`; it does not call package-specific scripts directly. Keep that pattern when adding smoke coverage so local and CI behavior stay aligned. +## Native Go Fuzzing + +Bomly runs native Go fuzzing nightly and on demand through the `Fuzz` workflow. The target matrix is intentionally explicit so CI spends time on the highest-risk untrusted input boundaries: + +- Node lockfile parsers for npm, pnpm, and Yarn +- SPDX/CycloneDX SBOM JSON detection and decoding +- SDK package URL canonicalization and transport JSON +- Managed plugin archive-name and relative-path sanitizers + +Run the same matrix locally with: + +```bash +make fuzz +make fuzz FUZZTIME=5s +``` + +`FUZZTIME` is passed to each `go test -fuzz` invocation and defaults to `60s`. When Go minimizes a failure into `testdata/fuzz//`, rerun the exact command printed by `go test`, then commit the reproducer only after confirming it is a useful regression seed. + ## Local Dependency Graph Benchmark Bomly ships a hidden, local-only `benchmark` command for comparing native-detector output with GitHub Dependency Graph and Syft SBOMs. It is intentionally not called by GitHub Actions. diff --git a/internal/detectors/node/npm/npm_lockfile_fuzz_test.go b/internal/detectors/node/npm/npm_lockfile_fuzz_test.go new file mode 100644 index 00000000..3cc88c93 --- /dev/null +++ b/internal/detectors/node/npm/npm_lockfile_fuzz_test.go @@ -0,0 +1,62 @@ +package npm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +const maxFuzzInputSize = 1 << 20 + +func FuzzDepGraphFromNPMLockfile(f *testing.F) { + for _, seed := range []string{ + `{"name":"demo","version":"1.0.0","lockfileVersion":3,"packages":{"":{"name":"demo","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}},"node_modules/left-pad":{"name":"left-pad","version":"1.3.0","resolved":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz","integrity":"sha512-seed"}}}`, + `{"name":"demo","version":"1.0.0","lockfileVersion":1,"dependencies":{"left-pad":{"version":"1.3.0","dependencies":{"repeat-string":{"version":"1.6.1"}}}}}`, + `{"name":"demo","lockfileVersion":3,"packages":{"":{"name":"demo","dependencies":{"benchmark":"1.0.0"}},"node_modules/benchmark":{"version":"1.0.0","engines":["node","rhino"]}}}`, + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxFuzzInputSize { + return + } + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "package-lock.json"), raw, 0o644); err != nil { + t.Fatalf("write package-lock.json: %v", err) + } + + graph, err := depGraphFromNPMLockfile(projectDir) + if err != nil { + return + } + requireFuzzGraphValid(t, graph) + }) +} + +func requireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { + t.Helper() + if graph == nil { + t.Fatal("successful parse returned nil graph") + } + graph.WalkNodes(func(node *sdk.Dependency) bool { + if node == nil { + t.Fatal("graph contains nil node") + } + if node.ID == "" { + t.Fatalf("graph contains node with empty ID: %+v", node) + } + return true + }) + graph.WalkEdges(func(from, to *sdk.Dependency) bool { + if from == nil || to == nil { + t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) + } + if from.ID == "" || to.ID == "" { + t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) + } + return true + }) +} diff --git a/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go b/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go new file mode 100644 index 00000000..a7f9e5ea --- /dev/null +++ b/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go @@ -0,0 +1,65 @@ +package pnpm + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +const maxFuzzInputSize = 1 << 20 + +func FuzzDepGraphFromPNPMLockfile(f *testing.F) { + for _, seed := range []string{ + "lockfileVersion: '9.0'\nimporters:\n .:\n dependencies:\n left-pad:\n version: 1.3.0\npackages:\n left-pad@1.3.0:\n resolution:\n integrity: sha512-seed\nsnapshots:\n left-pad@1.3.0: {}\n", + "lockfileVersion: 5.4\ndependencies:\n react: 18.2.0\npackages:\n /react/18.2.0:\n resolution:\n integrity: sha512-seed\n dependencies:\n loose-envify: 1.4.0\n /loose-envify/1.4.0:\n resolution:\n integrity: sha512-seed\n", + "packages:\n /@scope/pkg/1.0.0:\n resolution:\n tarball: https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz\n", + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxFuzzInputSize { + return + } + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "pnpm-lock.yaml"), raw, 0o644); err != nil { + t.Fatalf("write pnpm-lock.yaml: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"demo","version":"1.0.0"}`), 0o644); err != nil { + t.Fatalf("write package.json: %v", err) + } + + graph, err := depGraphFromPNPMLockfile(projectDir) + if err != nil { + return + } + requireFuzzGraphValid(t, graph) + }) +} + +func requireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { + t.Helper() + if graph == nil { + t.Fatal("successful parse returned nil graph") + } + graph.WalkNodes(func(node *sdk.Dependency) bool { + if node == nil { + t.Fatal("graph contains nil node") + } + if node.ID == "" { + t.Fatalf("graph contains node with empty ID: %+v", node) + } + return true + }) + graph.WalkEdges(func(from, to *sdk.Dependency) bool { + if from == nil || to == nil { + t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) + } + if from.ID == "" || to.ID == "" { + t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) + } + return true + }) +} diff --git a/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go b/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go new file mode 100644 index 00000000..1f082140 --- /dev/null +++ b/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go @@ -0,0 +1,65 @@ +package yarn + +import ( + "os" + "path/filepath" + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +const maxFuzzInputSize = 1 << 20 + +func FuzzDepGraphFromYarnLockfile(f *testing.F) { + for _, seed := range []string{ + "left-pad@^1.3.0:\n version \"1.3.0\"\n resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz\"\n integrity sha512-seed\n", + "\"@scope/pkg@npm:1.0.0\":\n version: 1.0.0\n resolution: \"@scope/pkg@npm:1.0.0\"\n dependencies:\n left-pad: ^1.3.0\nleft-pad@^1.3.0:\n version: 1.3.0\n", + "__metadata:\n version: 8\n cacheKey: 10\n", + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxFuzzInputSize { + return + } + projectDir := t.TempDir() + if err := os.WriteFile(filepath.Join(projectDir, "yarn.lock"), raw, 0o644); err != nil { + t.Fatalf("write yarn.lock: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"demo","version":"1.0.0","dependencies":{"left-pad":"^1.3.0"}}`), 0o644); err != nil { + t.Fatalf("write package.json: %v", err) + } + + graph, err := depGraphFromYarnLockfile(projectDir) + if err != nil { + return + } + requireFuzzGraphValid(t, graph) + }) +} + +func requireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { + t.Helper() + if graph == nil { + t.Fatal("successful parse returned nil graph") + } + graph.WalkNodes(func(node *sdk.Dependency) bool { + if node == nil { + t.Fatal("graph contains nil node") + } + if node.ID == "" { + t.Fatalf("graph contains node with empty ID: %+v", node) + } + return true + }) + graph.WalkEdges(func(from, to *sdk.Dependency) bool { + if from == nil || to == nil { + t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) + } + if from.ID == "" || to.ID == "" { + t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) + } + return true + }) +} diff --git a/internal/plugin/path_fuzz_test.go b/internal/plugin/path_fuzz_test.go new file mode 100644 index 00000000..5b95ddf9 --- /dev/null +++ b/internal/plugin/path_fuzz_test.go @@ -0,0 +1,67 @@ +package plugin + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func FuzzPluginPathSanitizers(f *testing.F) { + for _, seed := range []string{ + "bin/bomly-plugin", + "../escape", + "/absolute/path", + `.\\`, + `windows\path\plugin.exe`, + "plugin.tar.gz", + "plugin.zip", + "C:/escape/plugin.exe", + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, raw string) { + if len(raw) > 4096 { + return + } + + if name := safeDownloadArchiveName(raw); name != "" { + if strings.ContainsAny(name, `/\`) { + t.Fatalf("safe archive name contains path separator: %q from %q", name, raw) + } + if name == "." || name == ".." || strings.Contains(name, ":") { + t.Fatalf("safe archive name is unsafe: %q from %q", name, raw) + } + switch ext := archiveExtension(name); ext { + case "", ".zip", ".tar.gz", ".tgz": + default: + t.Fatalf("unexpected archive extension %q for %q", ext, name) + } + } + + cleanPath, err := cleanRelativePluginPath(raw) + if err != nil { + return + } + if cleanPath == "" || cleanPath == "." || filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, ":") { + t.Fatalf("clean relative path is unsafe: %q from %q", cleanPath, raw) + } + if cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) { + t.Fatalf("clean relative path escapes base: %q from %q", cleanPath, raw) + } + + root := t.TempDir() + fullPath, err := pathInPluginDir(root, raw) + if err != nil { + t.Fatalf("pathInPluginDir rejected path accepted by cleanRelativePluginPath: %q: %v", raw, err) + } + rel, err := filepath.Rel(root, fullPath) + if err != nil { + t.Fatalf("rel path for %q: %v", fullPath, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + t.Fatalf("pathInPluginDir returned escaping path: root=%q full=%q rel=%q", root, fullPath, rel) + } + }) +} diff --git a/internal/plugin/types.go b/internal/plugin/types.go index b683950a..6fb59952 100644 --- a/internal/plugin/types.go +++ b/internal/plugin/types.go @@ -252,6 +252,9 @@ func cleanRelativePluginPath(value string) (string, error) { if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(os.PathSeparator)) { return "", errors.New("path escapes base directory") } + if cleanPath == "." { + return "", errors.New("invalid relative path") + } return cleanPath, nil } diff --git a/internal/sbom/codec_fuzz_test.go b/internal/sbom/codec_fuzz_test.go new file mode 100644 index 00000000..23018f65 --- /dev/null +++ b/internal/sbom/codec_fuzz_test.go @@ -0,0 +1,33 @@ +package sbom + +import "testing" + +const maxFuzzInputSize = 1 << 20 + +func FuzzUnmarshalAutoJSON(f *testing.F) { + for _, seed := range []string{ + `{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","name":"demo","documentNamespace":"https://example.com/spdx/demo","creationInfo":{"created":"2026-01-01T00:00:00Z","creators":["Tool: bomly-fuzz"]},"packages":[]}`, + `{"bomFormat":"CycloneDX","specVersion":"1.4","version":1,"components":[]}`, + `{"bomFormat":"CycloneDX","specVersion":"1.5","version":1,"components":[]}`, + `{"bomFormat":"CycloneDX","specVersion":"1.6","version":1,"components":[]}`, + `{"artifacts":[],"artifactRelationships":[],"source":{"type":"directory","target":"."},"descriptor":{"name":"syft","version":"seed"}}`, + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxFuzzInputSize { + return + } + doc, target, err := UnmarshalAutoJSON(raw) + if err != nil || target == TargetSyftJSON { + return + } + if doc == nil { + t.Fatalf("successful %s parse returned nil document", target) + } + if _, err := MarshalJSON(doc, target, EncodeOptions{}); err != nil { + return + } + }) +} diff --git a/scripts/run-fuzz.sh b/scripts/run-fuzz.sh new file mode 100755 index 00000000..7c95603c --- /dev/null +++ b/scripts/run-fuzz.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +FUZZTIME="${FUZZTIME:-60s}" + +targets=( + "github.com/bomly-dev/bomly-cli/internal/detectors/node/npm FuzzDepGraphFromNPMLockfile" + "github.com/bomly-dev/bomly-cli/internal/detectors/node/pnpm FuzzDepGraphFromPNPMLockfile" + "github.com/bomly-dev/bomly-cli/internal/detectors/node/yarn FuzzDepGraphFromYarnLockfile" + "github.com/bomly-dev/bomly-cli/internal/sbom FuzzUnmarshalAutoJSON" + "github.com/bomly-dev/bomly-cli/sdk FuzzCanonicalizePackageURL" + "github.com/bomly-dev/bomly-cli/sdk FuzzGraphJSON" + "github.com/bomly-dev/bomly-cli/sdk FuzzPackageRegistryJSON" + "github.com/bomly-dev/bomly-cli/internal/plugin FuzzPluginPathSanitizers" +) + +for target in "${targets[@]}"; do + package="${target%% *}" + fuzz="${target#* }" + echo "==> go test ${package} -run=^$ -fuzz=^${fuzz}$ -fuzztime=${FUZZTIME}" + go test "${package}" -run=^$ -fuzz="^${fuzz}$" -fuzztime="${FUZZTIME}" +done diff --git a/sdk/fuzz_test.go b/sdk/fuzz_test.go new file mode 100644 index 00000000..3969af94 --- /dev/null +++ b/sdk/fuzz_test.go @@ -0,0 +1,125 @@ +package sdk + +import ( + "encoding/json" + "testing" +) + +const maxFuzzInputSize = 1 << 20 + +func FuzzCanonicalizePackageURL(f *testing.F) { + for _, seed := range []string{ + "pkg:npm/%40scope/name@1.0.0", + "pkg:golang/github.com/bomly-dev/bomly-cli@v0.1.0", + "pkg:pypi/requests@2.31.0", + "not a package url", + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, raw string) { + if len(raw) > maxFuzzInputSize { + return + } + canonical := CanonicalizePackageURL(raw) + if canonical == "" { + return + } + if reparsed := ParsePackageURL(canonical); reparsed == nil { + t.Fatalf("canonical package URL does not parse: %q", canonical) + } + if again := CanonicalizePackageURL(canonical); again != canonical { + t.Fatalf("package URL canonicalization is not stable: %q then %q", canonical, again) + } + }) +} + +func FuzzGraphJSON(f *testing.F) { + for _, seed := range []string{ + `null`, + `{"nodes":[{"id":"app","name":"app","version":"1.0.0"},{"id":"dep","name":"dep","version":"2.0.0"}],"edges":[{"fromId":"app","toId":"dep"}]}`, + `{"nodes":[{"id":"pkg:npm/react@18.2.0","purl":"pkg:npm/react@18.2.0","name":"react","version":"18.2.0"}]}`, + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxFuzzInputSize { + return + } + var graph Graph + if err := json.Unmarshal(raw, &graph); err != nil { + return + } + requireFuzzGraphValid(t, &graph) + encoded, err := json.Marshal(&graph) + if err != nil { + t.Fatalf("marshal graph after successful unmarshal: %v", err) + } + var roundTrip Graph + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatalf("round-trip graph JSON does not unmarshal: %v", err) + } + }) +} + +func FuzzPackageRegistryJSON(f *testing.F) { + for _, seed := range []string{ + `null`, + `{"pkg:npm/react@18.2.0":{"name":"react","version":"18.2.0","purl":"pkg:npm/react@18.2.0"}}`, + `{"pkg:golang/github.com/bomly-dev/bomly-cli@v0.1.0":{"name":"github.com/bomly-dev/bomly-cli","version":"v0.1.0"}}`, + } { + f.Add([]byte(seed)) + } + + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > maxFuzzInputSize { + return + } + var registry PackageRegistry + if err := json.Unmarshal(raw, ®istry); err != nil { + return + } + for _, pkg := range registry.All() { + if pkg == nil { + t.Fatal("registry contains nil package after successful unmarshal") + } + if pkg.PURL == "" { + t.Fatalf("registry contains package with empty PURL: %+v", pkg) + } + } + encoded, err := json.Marshal(®istry) + if err != nil { + t.Fatalf("marshal registry after successful unmarshal: %v", err) + } + var roundTrip PackageRegistry + if err := json.Unmarshal(encoded, &roundTrip); err != nil { + t.Fatalf("round-trip registry JSON does not unmarshal: %v", err) + } + }) +} + +func requireFuzzGraphValid(t *testing.T, graph *Graph) { + t.Helper() + if graph == nil { + t.Fatal("nil graph") + } + graph.WalkNodes(func(node *Dependency) bool { + if node == nil { + t.Fatal("graph contains nil node") + } + if node.ID == "" { + t.Fatalf("graph contains node with empty ID: %+v", node) + } + return true + }) + graph.WalkEdges(func(from, to *Dependency) bool { + if from == nil || to == nil { + t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) + } + if from.ID == "" || to.ID == "" { + t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) + } + return true + }) +} From b01fb4400362f2090da2383718d903df7fc7a23f Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Fri, 3 Jul 2026 03:49:13 -0700 Subject: [PATCH 2/2] Address fuzzing review feedback --- internal/detectors/node/nodetest/fuzz.go | 36 +++++++++++++++++ .../node/npm/npm_lockfile_fuzz_test.go | 33 ++------------- .../node/pnpm/pnpm_lockfile_fuzz_test.go | 33 ++------------- .../node/yarn/yarn_lockfile_fuzz_test.go | 33 ++------------- sdk/fuzz_test.go | 40 +++++++++++++++---- 5 files changed, 77 insertions(+), 98 deletions(-) create mode 100644 internal/detectors/node/nodetest/fuzz.go diff --git a/internal/detectors/node/nodetest/fuzz.go b/internal/detectors/node/nodetest/fuzz.go new file mode 100644 index 00000000..8fe6a1ee --- /dev/null +++ b/internal/detectors/node/nodetest/fuzz.go @@ -0,0 +1,36 @@ +package nodetest + +import ( + "testing" + + "github.com/bomly-dev/bomly-cli/sdk" +) + +// MaxFuzzInputSize caps fuzz payloads for lockfile parser targets. +const MaxFuzzInputSize = 1 << 20 + +// RequireFuzzGraphValid verifies graph invariants shared by node lockfile fuzz tests. +func RequireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { + t.Helper() + if graph == nil { + t.Fatal("successful parse returned nil graph") + } + graph.WalkNodes(func(node *sdk.Dependency) bool { + if node == nil { + t.Fatal("graph contains nil node") + } + if node.ID == "" { + t.Fatalf("graph contains node with empty ID: %+v", node) + } + return true + }) + graph.WalkEdges(func(from, to *sdk.Dependency) bool { + if from == nil || to == nil { + t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) + } + if from.ID == "" || to.ID == "" { + t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) + } + return true + }) +} diff --git a/internal/detectors/node/npm/npm_lockfile_fuzz_test.go b/internal/detectors/node/npm/npm_lockfile_fuzz_test.go index 3cc88c93..53714144 100644 --- a/internal/detectors/node/npm/npm_lockfile_fuzz_test.go +++ b/internal/detectors/node/npm/npm_lockfile_fuzz_test.go @@ -5,11 +5,9 @@ import ( "path/filepath" "testing" - "github.com/bomly-dev/bomly-cli/sdk" + "github.com/bomly-dev/bomly-cli/internal/detectors/node/nodetest" ) -const maxFuzzInputSize = 1 << 20 - func FuzzDepGraphFromNPMLockfile(f *testing.F) { for _, seed := range []string{ `{"name":"demo","version":"1.0.0","lockfileVersion":3,"packages":{"":{"name":"demo","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}},"node_modules/left-pad":{"name":"left-pad","version":"1.3.0","resolved":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz","integrity":"sha512-seed"}}}`, @@ -20,7 +18,7 @@ func FuzzDepGraphFromNPMLockfile(f *testing.F) { } f.Fuzz(func(t *testing.T, raw []byte) { - if len(raw) > maxFuzzInputSize { + if len(raw) > nodetest.MaxFuzzInputSize { return } projectDir := t.TempDir() @@ -32,31 +30,6 @@ func FuzzDepGraphFromNPMLockfile(f *testing.F) { if err != nil { return } - requireFuzzGraphValid(t, graph) - }) -} - -func requireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { - t.Helper() - if graph == nil { - t.Fatal("successful parse returned nil graph") - } - graph.WalkNodes(func(node *sdk.Dependency) bool { - if node == nil { - t.Fatal("graph contains nil node") - } - if node.ID == "" { - t.Fatalf("graph contains node with empty ID: %+v", node) - } - return true - }) - graph.WalkEdges(func(from, to *sdk.Dependency) bool { - if from == nil || to == nil { - t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) - } - if from.ID == "" || to.ID == "" { - t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) - } - return true + nodetest.RequireFuzzGraphValid(t, graph) }) } diff --git a/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go b/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go index a7f9e5ea..63c92297 100644 --- a/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go +++ b/internal/detectors/node/pnpm/pnpm_lockfile_fuzz_test.go @@ -5,11 +5,9 @@ import ( "path/filepath" "testing" - "github.com/bomly-dev/bomly-cli/sdk" + "github.com/bomly-dev/bomly-cli/internal/detectors/node/nodetest" ) -const maxFuzzInputSize = 1 << 20 - func FuzzDepGraphFromPNPMLockfile(f *testing.F) { for _, seed := range []string{ "lockfileVersion: '9.0'\nimporters:\n .:\n dependencies:\n left-pad:\n version: 1.3.0\npackages:\n left-pad@1.3.0:\n resolution:\n integrity: sha512-seed\nsnapshots:\n left-pad@1.3.0: {}\n", @@ -20,7 +18,7 @@ func FuzzDepGraphFromPNPMLockfile(f *testing.F) { } f.Fuzz(func(t *testing.T, raw []byte) { - if len(raw) > maxFuzzInputSize { + if len(raw) > nodetest.MaxFuzzInputSize { return } projectDir := t.TempDir() @@ -35,31 +33,6 @@ func FuzzDepGraphFromPNPMLockfile(f *testing.F) { if err != nil { return } - requireFuzzGraphValid(t, graph) - }) -} - -func requireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { - t.Helper() - if graph == nil { - t.Fatal("successful parse returned nil graph") - } - graph.WalkNodes(func(node *sdk.Dependency) bool { - if node == nil { - t.Fatal("graph contains nil node") - } - if node.ID == "" { - t.Fatalf("graph contains node with empty ID: %+v", node) - } - return true - }) - graph.WalkEdges(func(from, to *sdk.Dependency) bool { - if from == nil || to == nil { - t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) - } - if from.ID == "" || to.ID == "" { - t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) - } - return true + nodetest.RequireFuzzGraphValid(t, graph) }) } diff --git a/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go b/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go index 1f082140..86c9a77d 100644 --- a/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go +++ b/internal/detectors/node/yarn/yarn_lockfile_fuzz_test.go @@ -5,11 +5,9 @@ import ( "path/filepath" "testing" - "github.com/bomly-dev/bomly-cli/sdk" + "github.com/bomly-dev/bomly-cli/internal/detectors/node/nodetest" ) -const maxFuzzInputSize = 1 << 20 - func FuzzDepGraphFromYarnLockfile(f *testing.F) { for _, seed := range []string{ "left-pad@^1.3.0:\n version \"1.3.0\"\n resolved \"https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz\"\n integrity sha512-seed\n", @@ -20,7 +18,7 @@ func FuzzDepGraphFromYarnLockfile(f *testing.F) { } f.Fuzz(func(t *testing.T, raw []byte) { - if len(raw) > maxFuzzInputSize { + if len(raw) > nodetest.MaxFuzzInputSize { return } projectDir := t.TempDir() @@ -35,31 +33,6 @@ func FuzzDepGraphFromYarnLockfile(f *testing.F) { if err != nil { return } - requireFuzzGraphValid(t, graph) - }) -} - -func requireFuzzGraphValid(t *testing.T, graph *sdk.Graph) { - t.Helper() - if graph == nil { - t.Fatal("successful parse returned nil graph") - } - graph.WalkNodes(func(node *sdk.Dependency) bool { - if node == nil { - t.Fatal("graph contains nil node") - } - if node.ID == "" { - t.Fatalf("graph contains node with empty ID: %+v", node) - } - return true - }) - graph.WalkEdges(func(from, to *sdk.Dependency) bool { - if from == nil || to == nil { - t.Fatalf("graph contains nil edge endpoint: from=%+v to=%+v", from, to) - } - if from.ID == "" || to.ID == "" { - t.Fatalf("graph contains edge with empty endpoint ID: from=%+v to=%+v", from, to) - } - return true + nodetest.RequireFuzzGraphValid(t, graph) }) } diff --git a/sdk/fuzz_test.go b/sdk/fuzz_test.go index 3969af94..4623cca2 100644 --- a/sdk/fuzz_test.go +++ b/sdk/fuzz_test.go @@ -60,6 +60,8 @@ func FuzzGraphJSON(f *testing.F) { if err := json.Unmarshal(encoded, &roundTrip); err != nil { t.Fatalf("round-trip graph JSON does not unmarshal: %v", err) } + requireFuzzGraphValid(t, &roundTrip) + requireStableJSON(t, "graph", &graph, &roundTrip) }) } @@ -80,14 +82,7 @@ func FuzzPackageRegistryJSON(f *testing.F) { if err := json.Unmarshal(raw, ®istry); err != nil { return } - for _, pkg := range registry.All() { - if pkg == nil { - t.Fatal("registry contains nil package after successful unmarshal") - } - if pkg.PURL == "" { - t.Fatalf("registry contains package with empty PURL: %+v", pkg) - } - } + requireFuzzRegistryValid(t, ®istry) encoded, err := json.Marshal(®istry) if err != nil { t.Fatalf("marshal registry after successful unmarshal: %v", err) @@ -96,9 +91,38 @@ func FuzzPackageRegistryJSON(f *testing.F) { if err := json.Unmarshal(encoded, &roundTrip); err != nil { t.Fatalf("round-trip registry JSON does not unmarshal: %v", err) } + requireFuzzRegistryValid(t, &roundTrip) + requireStableJSON(t, "package registry", ®istry, &roundTrip) }) } +func requireStableJSON(t *testing.T, label string, before any, after any) { + t.Helper() + beforeJSON, err := json.Marshal(before) + if err != nil { + t.Fatalf("marshal %s before comparison: %v", label, err) + } + afterJSON, err := json.Marshal(after) + if err != nil { + t.Fatalf("marshal %s after comparison: %v", label, err) + } + if string(afterJSON) != string(beforeJSON) { + t.Fatalf("%s changed after round trip:\nbefore: %s\nafter: %s", label, beforeJSON, afterJSON) + } +} + +func requireFuzzRegistryValid(t *testing.T, registry *PackageRegistry) { + t.Helper() + for _, pkg := range registry.All() { + if pkg == nil { + t.Fatal("registry contains nil package after successful unmarshal") + } + if pkg.PURL == "" { + t.Fatalf("registry contains package with empty PURL: %+v", pkg) + } + } +} + func requireFuzzGraphValid(t *testing.T, graph *Graph) { t.Helper() if graph == nil {