fix(binary): preserve semver build metadata in ffmpeg version (#5452)

Signed-off-by: Adam Shaver <ashaver@nvidia.com>
This commit is contained in:
akshaver
2026-10-21 17:10:43 -04:00
committed by GitHub
parent f15c3343e2
commit b55707f60f
2 changed files with 146 additions and 5 deletions
@@ -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 <ver>" 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 <ver>" (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
+16 -5
View File
@@ -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<version>[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<version>[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<version>[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<version>[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<version>[0-9]+\.[0-9]+\.[0-9]+)`),
m.FileContentsVersionMatcher(`(?m)Lavf(?P<version>[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<version>[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<version>[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),