diff --git a/syft/pkg/cataloger/binary/classifier_cataloger_test.go b/syft/pkg/cataloger/binary/classifier_cataloger_test.go index a9d0a676e..20a4711b7 100644 --- a/syft/pkg/cataloger/binary/classifier_cataloger_test.go +++ b/syft/pkg/cataloger/binary/classifier_cataloger_test.go @@ -1,6 +1,7 @@ package binary import ( + "bytes" "context" "errors" "flag" @@ -20,6 +21,7 @@ import ( "github.com/anchore/syft/internal/testutils" "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/internal/unionreader" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/binary/internal/manager/testutil" "github.com/anchore/syft/syft/pkg/cataloger/internal/binutils" @@ -3192,6 +3194,134 @@ func Test_Cataloger_DefaultClassifiers_PositiveCases_Image(t *testing.T) { // Test_Cataloger_PackageTypes covers what assertPackagesAreEqual deliberately ignores: the package type and CPEs. // The perl interpreter is reported as cpan so it lines up with the rest of the CPAN ecosystem, everything else // keeps the default binary type. +// Test_Cataloger_ffmpeg_versionSuffix verifies the ffmpeg classifiers no longer +// unconditionally truncate the embedded "FFmpeg version " string to X.Y[.Z]. +// Semver build metadata (a backported-patch stamp such as +// "9.0.3+dev.9.0.2.p1.eb9cef3") must be preserved in the reported version, because +// it compares equal to its core version and so does not disturb vulnerability +// matching. A "-" pre-release suffix (a distro revision such as "6.1.1-3ubuntu5") +// must stay truncated: it sorts below the release in semver and would corrupt +// version-range matching. Suffix-free binaries match exactly as before, and +// trailing non-version text (or NUL padding) is never absorbed into the version. +func Test_Cataloger_ffmpeg_versionSuffix(t *testing.T) { + // collect the ffmpeg classifiers under test, keyed by their FileGlob + classifiers := map[string]binutils.Classifier{} + for _, c := range DefaultClassifiers() { + if c.Package == "ffmpeg" { + classifiers[c.FileGlob] = c + } + } + require.NotEmpty(t, classifiers, "expected to find ffmpeg classifiers") + + tests := []struct { + name string + fileGlob string // which classifier to run + data string // simulated binary contents + want string // expected reported version ("" = no match expected) + }{ + { + name: "library bare version unchanged", + fileGlob: "**/libav*", + data: "FFmpeg version 7.1.1", + want: "7.1.1", + }, + { + name: "library two-component version unchanged", + fileGlob: "**/libav*", + data: "FFmpeg version 6.0", + want: "6.0", + }, + { + name: "library build metadata with patch provenance preserved", + fileGlob: "**/libav*", + data: "FFmpeg version 9.0.3+dev.9.0.2.p1.eb9cef3", + want: "9.0.3+dev.9.0.2.p1.eb9cef3", + }, + { + name: "library build metadata short sha preserved", + fileGlob: "**/libav*", + data: "FFmpeg version 9.0.2+eb9cef3", + want: "9.0.2+eb9cef3", + }, + { + // a "-" pre-release suffix is intentionally NOT captured: it sorts below + // the release in semver and would corrupt version-range vulnerability + // matching, so distro revisions stay truncated exactly as before + name: "library distro hyphen revision truncated", + fileGlob: "**/libav*", + data: "FFmpeg version 6.1.1-3ubuntu5", + want: "6.1.1", + }, + { + name: "library distro dotted hyphen revision truncated", + fileGlob: "**/libav*", + data: "FFmpeg version 4.4.2-0ubuntu0.22.04.1", + want: "4.4.2", + }, + { + name: "library trailing text not captured", + fileGlob: "**/libav*", + data: "FFmpeg version 6.1.1 Copyright (c) 2000-2023 the FFmpeg developers", + want: "6.1.1", + }, + { + name: "library NUL-padded version not over-captured", + fileGlob: "**/libav*", + data: "FFmpeg version 9.0.3\x00libavformat.so", + want: "9.0.3", + }, + { + name: "libswresample build metadata preserved", + fileGlob: "**/libswresample*", + data: "FFmpeg version 9.0.3+dev.9.0.2.p1.eb9cef3", + want: "9.0.3+dev.9.0.2.p1.eb9cef3", + }, + { + // the compiled-in printf format string in the ffmpeg binary is the + // literal "%s version " (the program name is substituted at runtime) + name: "binary build metadata preserved", + fileGlob: "**/ffmpeg", + data: "%s version 9.0.3+dev.9.0.2.p1.eb9cef3", + want: "9.0.3+dev.9.0.2.p1.eb9cef3", + }, + { + name: "binary bare version unchanged", + fileGlob: "**/ffmpeg", + data: "%s version 7.1.1", + want: "7.1.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cls, ok := classifiers[tt.fileGlob] + require.Truef(t, ok, "no ffmpeg classifier for glob %q", tt.fileGlob) + + ctx := binutils.MatcherContext{ + GetReader: func(binutils.MatcherContext) (unionreader.UnionReader, error) { + return unionreader.GetUnionReader(io.NopCloser(bytes.NewBufferString(tt.data))) + }, + } + + pkgs, err := cls.EvidenceMatcher(cls, ctx) + require.NoError(t, err) + + if tt.want == "" { + require.Empty(t, pkgs, "expected no package match") + return + } + require.Len(t, pkgs, 1, "expected exactly one package") + assert.Equal(t, tt.want, pkgs[0].Version) + // the fuller version must also flow into the PURL so the SBOM is not lossy; + // compare the decoded PURL version, since packageurl-go percent-encodes + // reserved characters (e.g. '+' -> %2B) in the serialized form + parsed, err := packageurl.FromString(pkgs[0].PURL) + require.NoError(t, err, "PURL should parse") + assert.Equal(t, tt.want, parsed.Version) + }) + } +} + func Test_Cataloger_PackageTypes(t *testing.T) { tests := []struct { logicalFixture string diff --git a/syft/pkg/cataloger/binary/classifiers.go b/syft/pkg/cataloger/binary/classifiers.go index 4e4afb478..a23f9d1f4 100644 --- a/syft/pkg/cataloger/binary/classifiers.go +++ b/syft/pkg/cataloger/binary/classifiers.go @@ -972,7 +972,10 @@ func DefaultClassifiers() []binutils.Classifier { EvidenceMatcher: m.FileContentsVersionMatcher( // Pattern found in the binary: "%s version 7.1.1" or "%s version 6.0" // When executed outputs: "ffmpeg version 7.1.1" or "ffmpeg version 6.0" - `(?m)%s version (?P[0-9]+\.[0-9]+(\.[0-9]+)?)`, + // The optional "+" suffix preserves semver build metadata (e.g. a + // backported-patch stamp like "9.0.3+dev.9.0.2.p1.eb9cef3"); see the + // ffmpeg-library classifier for why "-" pre-release suffixes stay truncated. + `(?m)%s version (?P[0-9]+\.[0-9]+(\.[0-9]+)?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)`, ), Package: "ffmpeg", PURL: mustPURL("pkg:generic/ffmpeg@version"), @@ -982,8 +985,15 @@ func DefaultClassifiers() []binutils.Classifier { Class: "ffmpeg-library", FileGlob: "**/libav*", EvidenceMatcher: binutils.MatchAny( - // Primary pattern: FFmpeg version found in most libraries - m.FileContentsVersionMatcher(`(?m)FFmpeg version (?P[0-9]+\.[0-9]+(\.[0-9]+)?)`), + // Primary pattern: FFmpeg version found in most libraries. + // The optional "+" suffix preserves semver build metadata (e.g. a + // backported-patch stamp like "9.0.3+dev.9.0.2.p1.eb9cef3") instead of + // truncating to X.Y[.Z]. Build metadata compares equal to its core + // version, so this does not change vulnerability matching. A "-" + // pre-release suffix (e.g. a distro revision "6.1.1-3ubuntu5") is + // deliberately NOT captured: it sorts below the release in semver and + // would corrupt version-range matching, so it stays truncated as before. + m.FileContentsVersionMatcher(`(?m)FFmpeg version (?P[0-9]+\.[0-9]+(\.[0-9]+)?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)`), // Fallback: library-specific version patterns for libavcodec and libavformat m.FileContentsVersionMatcher(`(?m)Lavc(?P[0-9]+\.[0-9]+\.[0-9]+)`), m.FileContentsVersionMatcher(`(?m)Lavf(?P[0-9]+\.[0-9]+\.[0-9]+)`), @@ -996,8 +1006,9 @@ func DefaultClassifiers() []binutils.Classifier { Class: "ffmpeg-library", FileGlob: "**/libswresample*", EvidenceMatcher: m.FileContentsVersionMatcher( - // FFmpeg version pattern for libswresample - `(?m)FFmpeg version (?P[0-9]+\.[0-9]+(\.[0-9]+)?)`), + // FFmpeg version pattern for libswresample. Same optional "+" build + // metadata handling as the ffmpeg-library classifier above. + `(?m)FFmpeg version (?P[0-9]+\.[0-9]+(\.[0-9]+)?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?)`), Package: "ffmpeg", PURL: mustPURL("pkg:generic/ffmpeg@version"), CPEs: singleCPE("cpe:2.3:a:ffmpeg:ffmpeg:*:*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource),