diff --git a/config/tomlloader.go b/config/tomlloader.go index 642d98a8db..e20caa377c 100644 --- a/config/tomlloader.go +++ b/config/tomlloader.go @@ -298,7 +298,16 @@ func setDefaultIfEmpty(server *ServerInfo) error { return nil } -func toCpeURI(cpename string) (string, error) { +func toCpeURI(cpename string) (uri string, err error) { + // go-cpe's BindToURI / UnbindURI panic on some malformed inputs (e.g. + // embedded backslash-escapes); convert that into an error so a single + // bad CPE in user config can never bring the process down. + defer func() { + if r := recover(); r != nil { + uri = "" + err = xerrors.Errorf("Failed to parse CPE %q: %v", cpename, r) + } + }() if strings.HasPrefix(cpename, "cpe:2.3:") { wfn, err := naming.UnbindFS(cpename) if err != nil { diff --git a/config/tomlloader_fuzz_test.go b/config/tomlloader_fuzz_test.go new file mode 100644 index 0000000000..9c1184064d --- /dev/null +++ b/config/tomlloader_fuzz_test.go @@ -0,0 +1,40 @@ +package config + +import ( + "strings" + "testing" +) + +// FuzzToCpeURI drives random CPE strings through toCpeURI. Invariants: +// - never panic, +// - if err == nil, the returned URI starts with "cpe:/" (CPE 2.2 form). +func FuzzToCpeURI(f *testing.F) { + seeds := []string{ + `cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*`, + `cpe:2.3:o:linux:linux_kernel:5.10:*:*:*:*:*:*:*`, + `cpe:/a:vendor:product:1.0`, + `cpe:/o:linux:linux_kernel:5.10`, + `cpe:/`, + `cpe:2.3:`, + `cpe:2.3`, + `cpe:2.3:a:`, + `cpe:/a:`, + ``, + `not-a-cpe`, + `cpe:2.3:a:v:p:1\:2:*:*:*:*:*:*:*`, + `cpe:/a:vendor:product:1\:2`, + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, cpename string) { + got, err := toCpeURI(cpename) + if err != nil { + return + } + if !strings.HasPrefix(got, "cpe:/") { + t.Fatalf("toCpeURI(%q) = %q: missing cpe:/ prefix", cpename, got) + } + }) +} diff --git a/contrib/snmp2cpe/pkg/cpe/cpe.go b/contrib/snmp2cpe/pkg/cpe/cpe.go index a8b95c7f20..87762cd4c6 100644 --- a/contrib/snmp2cpe/pkg/cpe/cpe.go +++ b/contrib/snmp2cpe/pkg/cpe/cpe.go @@ -68,9 +68,13 @@ func Convert(result snmp.Result) []string { } else { h, v, ok := strings.Cut(result.SysDescr0, " version ") if ok { + vfields := strings.Fields(v) + if len(vfields) == 0 { + break + } cpes = append(cpes, fmt.Sprintf("cpe:2.3:h:juniper:%s:-:*:*:*:*:*:*:*", strings.ToLower(h)), - fmt.Sprintf("cpe:2.3:o:juniper:screenos:%s:*:*:*:*:*:*:*", strings.ToLower(strings.Fields(v)[0])), + fmt.Sprintf("cpe:2.3:o:juniper:screenos:%s:*:*:*:*:*:*:*", strings.ToLower(vfields[0])), ) } } diff --git a/contrib/snmp2cpe/pkg/cpe/cpe_fuzz_test.go b/contrib/snmp2cpe/pkg/cpe/cpe_fuzz_test.go new file mode 100644 index 0000000000..2d720992c1 --- /dev/null +++ b/contrib/snmp2cpe/pkg/cpe/cpe_fuzz_test.go @@ -0,0 +1,52 @@ +package cpe + +import ( + "testing" + + "github.com/future-architect/vuls/contrib/snmp2cpe/pkg/snmp" +) + +// FuzzConvert drives random snmp.Result values through Convert. Strings are +// fed for SysDescr0, EntPhysicalMfgName, EntPhysicalName and +// EntPhysicalSoftwareRev. Invariant: never panic. +func FuzzConvert(f *testing.F) { + seeds := []struct{ desc, mfg, name, rev string }{ + {"Cisco IOS Software, Version 12.4", "Cisco", "C2960", "12.4"}, + {"Cisco NX-OS, Version 7.0", "Cisco", "Nexus", "7.0"}, + {"Juniper Networks, Inc. mx240, kernel JUNOS 15.1F6", "Juniper Networks", "MX240", "15.1F6"}, + {"Arista Networks EOS version 4.20.7M running on an Arista Networks DCS-7050S", "Arista Networks", "DCS-7050S", "4.20.7M"}, + {"FortiGate-60E v5.6.4,build1575", "Fortinet", "FGT_60E", "FortiGate-60E v5.6.4,build1575"}, + {"YAMAHA RTX1210 Rev.14.01.42", "YAMAHA", "RTX1210", "14.01.42"}, + {"NEC IX Series IX2215 (magellan-sec) Software, Version 10.10.10", "NEC", "IX2215", "10.10.10"}, + {"Palo Alto Networks PA-220", "Palo Alto Networks", "PA-220", "10.0.0"}, + {"", "", "", ""}, + {"Cisco", "", "", ""}, + {"Cisco IOS Software, Version", "", "", ""}, + {"Juniper Networks, Inc. ", "", "", ""}, + {"Arista Networks EOS version running on an ", "", "", ""}, + {"Arista Networks EOS version x running on an ", "", "", ""}, + {"FortiGate version", "", "", ""}, + {"YAMAHA RTX", "", "", ""}, + {"NEC", "", "", ""}, + {"foo version ", "Juniper Networks", "", ""}, + {"abc kernel JUNOS ", "Juniper Networks", "", ""}, + {"Juniper Networks, Inc. qfx", "Juniper Networks", "", ""}, + } + for _, s := range seeds { + f.Add(s.desc, s.mfg, s.name, s.rev) + } + + f.Fuzz(func(_ *testing.T, desc, mfg, ename, rev string) { + r := snmp.Result{ + SysDescr0: desc, + EntPhysicalTables: map[int]snmp.EntPhysicalTable{ + 1: { + EntPhysicalMfgName: mfg, + EntPhysicalName: ename, + EntPhysicalSoftwareRev: rev, + }, + }, + } + _ = Convert(r) + }) +} diff --git a/detector/library_fuzz_test.go b/detector/library_fuzz_test.go new file mode 100644 index 0000000000..b680dfaa86 --- /dev/null +++ b/detector/library_fuzz_test.go @@ -0,0 +1,55 @@ +//go:build !scanner + +package detector + +import ( + "strings" + "testing" + + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" +) + +// FuzzCanonicalMavenPURL drives random "groupId:artifactId" / version pairs +// through canonicalMavenPURL. It enforces: +// - the function never panics, +// - the empty-input guard ("" group, "" artifact, no ':') always returns "", +// - any non-empty result is a syntactically plausible Maven purl. +func FuzzCanonicalMavenPURL(f *testing.F) { + seeds := []struct{ name, version string }{ + {"org.apache.logging.log4j:log4j-core", "2.14.1"}, + {"org.springframework.boot:spring-boot-starter-jdbc", "2.5.0"}, + {"antlr:antlr", "2.7.7"}, + {"com.example:example", "1.0+build.1"}, + {"log4j-core", "2.14.1"}, + {":log4j-core", "2.14.1"}, + {"org.apache.logging.log4j:", "2.14.1"}, + {"", "2.14.1"}, + {"a:b", ""}, + {"a:b:c", "1"}, + {"a/b:c", "1.0"}, + {"a:b@c", "1.0"}, + } + for _, s := range seeds { + f.Add(s.name, s.version) + } + + f.Fuzz(func(t *testing.T, name, version string) { + got := canonicalMavenPURL(ftypes.Package{Name: name, Version: version}) + + group, artifact, hasColon := strings.Cut(name, ":") + if !hasColon || group == "" || artifact == "" { + if got != "" { + t.Fatalf("expected empty for non-canonical name %q, got %q", name, got) + } + return + } + + if got == "" { + // purl.New rejected the input — acceptable, no further checks. + return + } + if !strings.HasPrefix(got, "pkg:maven/") { + t.Fatalf("canonicalMavenPURL(%q,%q) = %q: missing pkg:maven/ prefix", name, version, got) + } + }) +} diff --git a/go.mod b/go.mod index 2f5f057595..f953c11467 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,13 @@ module github.com/future-architect/vuls go 1.26 +// stats/opentelemetry was absorbed into google.golang.org/grpc itself; the +// standalone module is still pulled in transitively (helm.sh/helm/v3 → +// distribution/distribution/v3@v3.0.0) and fails the build with an +// "ambiguous import" error. Exclude the standalone module so the absorbed +// sub-package is the only one resolved. +exclude google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a + require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 github.com/BurntSushi/toml v1.6.0 diff --git a/gost/redhat_fuzz_test.go b/gost/redhat_fuzz_test.go new file mode 100644 index 0000000000..0536765517 --- /dev/null +++ b/gost/redhat_fuzz_test.go @@ -0,0 +1,44 @@ +package gost + +import ( + "strings" + "testing" +) + +// FuzzParseCwe drives random RedHat-CWE-encoded strings through +// (RedHat).parseCwe. Invariants: +// - never panic, +// - no entry contains the splitter characters '(', ')' or "->", +// - no empty entry. +func FuzzParseCwe(f *testing.F) { + seeds := []string{ + "CWE-79", + "CWE-79->CWE-89", + "(CWE-79)", + "(CWE-79->CWE-89)", + "CWE-79->(CWE-89)", + "", + "->", + "()", + "((()))", + "->->->", + "a(b)c->d", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, str string) { + got := RedHat{}.parseCwe(str) + for _, c := range got { + if c == "" { + t.Fatalf("parseCwe(%q) emitted empty entry: %v", str, got) + } + for _, splitter := range []string{"(", ")", "->"} { + if strings.Contains(c, splitter) { + t.Fatalf("parseCwe(%q) entry %q still contains splitter %q", str, c, splitter) + } + } + } + }) +} diff --git a/scanner/alpine_fuzz_test.go b/scanner/alpine_fuzz_test.go new file mode 100644 index 0000000000..a8b246f1c1 --- /dev/null +++ b/scanner/alpine_fuzz_test.go @@ -0,0 +1,57 @@ +package scanner + +import ( + "testing" + + "github.com/future-architect/vuls/config" +) + +// FuzzParseApkIndex drives random APKINDEX-like blocks through +// (alpine).parseApkIndex. Invariants: +// - never panic, +// - if err == nil, every entry in the returned map has matching key/Name +// and a non-empty Version. +func FuzzParseApkIndex(f *testing.F) { + seeds := []string{ + "P:alpine-baselayout\nV:3.6.5-r0\nA:x86_64\no:alpine-baselayout\n", + "P:foo\nV:1.0\nA:noarch\n\nP:bar\nV:2.0\no:foo\n", + "P:foo\nV:1.0\n", + "", + "\n", + "\n\n", + "P:\nV:\n", + "P:foo\n", + "V:1.0\n", + "only-no-colon-line\n", + ":\n", + "P:a\nV:b\nP:c\nV:d\n", + "P:a\nV:b\n\nP:c\nV:d\n", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, stdout string) { + o := newAlpine(config.ServerInfo{}) + bins, srcs, err := o.parseApkIndex(stdout) + if err != nil { + return + } + for key, pkg := range bins { + if pkg.Name != key { + t.Fatalf("parseApkIndex(%q): bin map key %q != Name %q", stdout, key, pkg.Name) + } + if pkg.Version == "" { + t.Fatalf("parseApkIndex(%q): bin %q has empty Version", stdout, key) + } + } + for key, sp := range srcs { + if sp.Name != key { + t.Fatalf("parseApkIndex(%q): src map key %q != Name %q", stdout, key, sp.Name) + } + if sp.Version == "" { + t.Fatalf("parseApkIndex(%q): src %q has empty Version", stdout, key) + } + } + }) +} diff --git a/scanner/debian_fuzz_test.go b/scanner/debian_fuzz_test.go new file mode 100644 index 0000000000..aa738bb24f --- /dev/null +++ b/scanner/debian_fuzz_test.go @@ -0,0 +1,48 @@ +package scanner + +import ( + "strings" + "testing" +) + +// FuzzParseScannedPackagesLine drives random comma-separated lines through +// (debian).parseScannedPackagesLine. Invariants: +// - never panic, +// - if err == nil, name is non-empty and matches the first comma-separated +// field with any ":arch" suffix stripped. +func FuzzParseScannedPackagesLine(f *testing.F) { + seeds := []string{ + "linux-base,ii ,4.9,linux-base,4.9", + "linux-libc-dev:amd64,ii ,6.1.90-1,linux,6.1.90-1", + "linux-image-6.1.0-18-amd64,ii ,6.1.76-1,linux-signed-amd64,6.1.76+1", + "package,ii , , , ", + ",,,,", + "a,b,c,d,e", + "a:x,b,c,d,e", + "a:x:y,b,c,d,e", + "", + "only,three,fields", + "a,b,c,d,e,f", + "name:arch,status,version,srcname srcver,srcversion", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, line string) { + o := &debian{} + name, _, _, _, _, err := o.parseScannedPackagesLine(line) + if err != nil { + return + } + + ss := strings.Split(line, ",") + expected := ss[0] + if i := strings.IndexRune(expected, ':'); i >= 0 { + expected = expected[:i] + } + if name != expected { + t.Fatalf("parseScannedPackagesLine(%q): name=%q, want %q", line, name, expected) + } + }) +} diff --git a/scanner/freebsd.go b/scanner/freebsd.go index 7a7682eb06..95fa9a10bd 100644 --- a/scanner/freebsd.go +++ b/scanner/freebsd.go @@ -280,6 +280,12 @@ func (o *bsd) parsePkgVersion(stdout string) models.Packages { Version: ver, } case "<": + // Expected format: "- < needs updating (index has )" + // — 7 whitespace-separated fields. A truncated line that survives + // the earlier len < 2 guard would otherwise panic on fields[6]. + if len(fields) < 7 { + continue + } candidate := strings.TrimSuffix(fields[6], ")") packs[name] = models.Package{ Name: name, @@ -331,11 +337,18 @@ func (o *bsd) parseBlock(block string) (packName string, cveIDs []string, vulnID lines := strings.SplitSeq(block, "\n") for l := range lines { if strings.HasSuffix(l, " is vulnerable:") { - packVer := strings.Fields(l)[0] - splitted := strings.Split(packVer, "-") + fields := strings.Fields(l) + if len(fields) == 0 { + continue + } + splitted := strings.Split(fields[0], "-") packName = strings.Join(splitted[:len(splitted)-1], "-") } else if strings.HasPrefix(l, "CVE:") { - cveIDs = append(cveIDs, strings.Fields(l)[1]) + fields := strings.Fields(l) + if len(fields) < 2 { + continue + } + cveIDs = append(cveIDs, fields[1]) } else if strings.HasPrefix(l, "WWW:") { splitted := strings.Split(l, "/") vulnID = strings.TrimSuffix(splitted[len(splitted)-1], ".html") diff --git a/scanner/freebsd_fuzz_test.go b/scanner/freebsd_fuzz_test.go new file mode 100644 index 0000000000..938f9967fc --- /dev/null +++ b/scanner/freebsd_fuzz_test.go @@ -0,0 +1,69 @@ +package scanner + +import ( + "testing" + + "github.com/future-architect/vuls/config" +) + +// FuzzParsePkgVersion drives random `pkg version` outputs through +// parsePkgVersion. Invariants: never panic, returned map keys equal Name. +func FuzzParsePkgVersion(f *testing.F) { + seeds := []string{ + "foo-1.0 ?\n", + "bar-2.3.4 =\n", + "baz-1.0 < needs updating (index has 2.0)\n", + "qux-1.0 > newer than index\n", + "", + "\n", + " \n", + "a b\n", + "- =\n", + "-1.0 ?\n", + "name-only\n", + "name -\n", + "name <\n", + "name < ( ( ( ( ( ( extra)\n", + "name- ?\n", + "foo-1.0 < a b c d e\n", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, stdout string) { + o := newBsd(config.ServerInfo{}) + packs := o.parsePkgVersion(stdout) + for key, pkg := range packs { + if pkg.Name != key { + t.Fatalf("parsePkgVersion(%q): map key %q != Name %q", stdout, key, pkg.Name) + } + } + }) +} + +// FuzzParseBlock drives random pkg-audit blocks through parseBlock. +// Invariants: never panic. +func FuzzParseBlock(f *testing.F) { + seeds := []string{ + "foo-1.0 is vulnerable:\nCVE: CVE-2024-1234\nWWW: https://vuxml.org/freebsd/abc.html\n", + "is vulnerable:\nCVE:\nWWW:\n", + " is vulnerable:\nCVE: x\nWWW: /a/b.html\n", + "foo is vulnerable:\nCVE: A B C\n", + "", + "\n", + "CVE:\n", + "WWW:\n", + "CVE:\nWWW:\n", + "x-1 is vulnerable:\n", + "x-1 is vulnerable:\nCVE:foo\n", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(_ *testing.T, block string) { + o := newBsd(config.ServerInfo{}) + _, _, _ = o.parseBlock(block) + }) +} diff --git a/scanner/redhatbase.go b/scanner/redhatbase.go index 93a44ae560..485e7d3519 100644 --- a/scanner/redhatbase.go +++ b/scanner/redhatbase.go @@ -736,11 +736,21 @@ func splitFileName(filename string) (name, ver, rel, epoch, arch string, err err ver = basename[verIndex+1 : relIndex] epochIndex := strings.Index(basename, ":") - if epochIndex != -1 { + // The doc-comment format puts the optional epoch as a leading ":" + // prefix. A colon found anywhere past the version boundary belongs to + // some other field (e.g. "name-1:1.40.16-rel.arch", emitted by some + // dnf builds) and must not be treated as an epoch — otherwise the + // `basename[epochIndex+1 : verIndex]` slice below inverts and panics. + if epochIndex != -1 && epochIndex < verIndex { epoch = basename[:epochIndex] + } else { + epochIndex = -1 } name = basename[epochIndex+1 : verIndex] + if name == "" { + return "", "", "", "", "", xerrors.Errorf("unexpected file name. expected: %q, actual: %q", "(:)--()(.|-).rpm", filename) + } return name, ver, rel, epoch, arch, nil } diff --git a/scanner/redhatbase_fuzz_test.go b/scanner/redhatbase_fuzz_test.go new file mode 100644 index 0000000000..16cdcd34e1 --- /dev/null +++ b/scanner/redhatbase_fuzz_test.go @@ -0,0 +1,114 @@ +package scanner + +import ( + "strings" + "testing" +) + +// FuzzParseInstalledPackagesLine drives random whitespace-separated lines +// through (redhatBase).parseInstalledPackagesLine. Invariants: +// - never panic, +// - if err == nil, the returned Package is non-nil and Name matches the +// first whitespace-separated field of the input. +func FuzzParseInstalledPackagesLine(f *testing.F) { + seeds := []string{ + "gpg-pubkey (none) f5282ee4 58ac92a3 (none) (none)", + "bar 1 9 123a ia64 1:bar-9-123a.src.rpm", + "openssl-libs 1 1.1.0h 3.fc27 x86_64 openssl-1.1.0h-3.fc27.src.rpm", + "dnf 0 4.14.0 1.fc35 noarch dnf-4.14.0-1.fc35.src.rpm (none)", + "community-mysql 0 8.0.31 1.module_f35+15642+4eed9dbd x86_64 community-mysql-8.0.31-1.module_f35+15642+4eed9dbd.src.rpm mysql:8.0:3520221024193033:f27b74a8", + "elasticsearch 0 8.17.0 1 x86_64 elasticsearch-8.17.0-1-src.rpm (none)", + "package 0 0 1 x86_64 package-0-1-src.rpm (none)", + "package 0 0 x86_64 package-0--src.rpm (none)", + "a b c d e f", + "a b c d e f g", + "", + "a", + "a b c d e :", + "a b c d e -", + "a b c d e .", + "a b c d e .rpm", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, line string) { + o := &redhatBase{} + bp, _, err := o.parseInstalledPackagesLine(line) + if err != nil { + return + } + if bp == nil { + t.Fatalf("parseInstalledPackagesLine(%q): nil err but Package is nil", line) + } + fields := strings.Split(line, " ") + if bp.Name != fields[0] { + t.Fatalf("parseInstalledPackagesLine(%q): Name=%q, want %q", line, bp.Name, fields[0]) + } + }) +} + +// FuzzSplitFileName drives random RPM-like file names through splitFileName. +// Invariants: +// - the function never panics (Go fuzzing detects this automatically), +// - if err == nil, all returned substrings come from the input (no +// character invention), +// - if err == nil, the package name is non-empty (an empty Name silently +// emitted would corrupt the resulting models.Package downstream). +// +// The doc comment explicitly accepts cases like "qux-0--i386" where rel +// ends up empty, so empty rel/epoch are tolerated. +func FuzzSplitFileName(f *testing.F) { + seeds := []string{ + "foo-1.0-1.i386.rpm", + "1:bar-9-123a.ia64.rpm", + "baz-0-1-i386", + "qux-0--i386", + "Percona-Server-shared-56-1:5.6.19-rel67.0.el6.x86_64", + "kernel-3.10.0-1160.el7.x86_64.rpm", + "glibc-2.17-326.el7_9.x86_64", + "NetworkManager-1:1.40.16-1.el8.x86_64.rpm", + "", + ".rpm", + "a.rpm", + "a-b.rpm", + "a-b-c.rpm", + "a-b-c.d.rpm", + ":", + "-", + "-.", + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, filename string) { + name, ver, rel, epoch, arch, err := splitFileName(filename) + if err != nil { + return + } + + basename := strings.TrimSuffix(filename, ".rpm") + for _, p := range []struct { + label string + value string + }{ + {"name", name}, + {"ver", ver}, + {"rel", rel}, + {"epoch", epoch}, + {"arch", arch}, + } { + if p.value != "" && !strings.Contains(basename, p.value) { + t.Fatalf("splitFileName(%q): %s=%q not found in basename %q", + filename, p.label, p.value, basename) + } + } + + if name == "" { + t.Fatalf("splitFileName(%q) returned nil err but Name is empty: ver=%q rel=%q epoch=%q arch=%q", + filename, ver, rel, epoch, arch) + } + }) +} diff --git a/scanner/suse_fuzz_test.go b/scanner/suse_fuzz_test.go new file mode 100644 index 0000000000..d4216cca39 --- /dev/null +++ b/scanner/suse_fuzz_test.go @@ -0,0 +1,38 @@ +package scanner + +import ( + "strings" + "testing" +) + +// FuzzParseOSRelease drives random /etc/os-release content through +// (suse).parseOSRelease. Invariants: never panic; if name is non-empty then +// VERSION_ID was matched (so ver is non-empty too). +func FuzzParseOSRelease(f *testing.F) { + seeds := []string{ + `CPE_NAME="cpe:/o:opensuse:opensuse:42.3"` + "\n" + `VERSION_ID="42.3"`, + `CPE_NAME="cpe:/o:opensuse:tumbleweed:..."` + "\n" + `VERSION_ID="20231106"`, + `CPE_NAME="cpe:/o:opensuse:leap:15.5"` + "\n" + `VERSION_ID="15.5"`, + `CPE_NAME="cpe:/o:suse:sles:15:sp4"` + "\n" + `VERSION_ID="15.4"`, + `CPE_NAME="cpe:/o:suse:sled:15"` + "\n" + `VERSION_ID="15"`, + "", + "\n", + `VERSION_ID=""`, + `CPE_NAME="cpe:/o:opensuse:opensuse"`, + `CPE_NAME="cpe:/o:opensuse:opensuse"` + "\n" + `VERSION_ID=""`, + } + for _, s := range seeds { + f.Add(s) + } + + f.Fuzz(func(t *testing.T, content string) { + o := &suse{} + name, ver := o.parseOSRelease(content) + if name != "" { + // tumbleweed early-returns "tumbleweed" without consulting VERSION_ID. + if ver == "" && !strings.Contains(content, `CPE_NAME="cpe:/o:opensuse:tumbleweed`) { + t.Fatalf("parseOSRelease(%q) returned name=%q with empty ver", content, name) + } + } + }) +}