feat: Add support for scanning GGUF models from OCI registries (#4335)

---------
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Christopher Angelo Phillips
2026-02-09 16:05:52 -05:00
committed by GitHub
co-authored by Alex Goodman
parent 3a23cfff1d
commit 2c5e193f7a
51 changed files with 6127 additions and 238 deletions
+11 -2
View File
@@ -9,8 +9,17 @@ import (
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
const (
catalogerName = "gguf-cataloger"
ggufLayerMediaType = "application/vnd.docker.ai*"
)
// NewGGUFCataloger returns a new cataloger instance for GGUF model files.
// It supports both traditional file-based discovery and OCI layer-aware discovery
// when the source for the SBOM is the oci model source
func NewGGUFCataloger() pkg.Cataloger {
return generic.NewCataloger("gguf-cataloger").
WithParserByGlobs(parseGGUFModel, "**/*.gguf")
return generic.NewCataloger(catalogerName).
WithParserByGlobs(parseGGUFModel, "**/*.gguf").
WithParserByMediaType(parseGGUFModel, ggufLayerMediaType).
WithProcessors(ggufMergeProcessor)
}
@@ -122,6 +122,10 @@ func extractVersion(kvs gguf_parser.GGUFMetadataKVs) string {
// extractModelNameFromPath extracts the model name from the file path
func extractModelNameFromPath(path string) string {
// we do not want to return a name from filepath if it's not a distinct gguf file
if !strings.Contains(path, ".gguf") {
return ""
}
// Get the base filename
base := filepath.Base(path)
+59
View File
@@ -0,0 +1,59 @@
package ai
import (
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/pkg"
)
// ggufMergeProcessor consolidates multiple GGUF packages into a single package
// representing the AI model. When scanning OCI images with multiple layers,
// each layer may produce a separate package. This processor finds the package
// with a name and merges metadata from nameless packages into its GGUFFileParts field.
// Only packages with a non-empty name are returned in the final result.
func ggufMergeProcessor(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
if err != nil {
return pkgs, rels, err
}
if len(pkgs) == 0 {
return pkgs, rels, err
}
// Separate packages with names from those without
var namedPkgs []pkg.Package
var namelessHeaders []pkg.GGUFFileHeader
for _, p := range pkgs {
if p.Name != "" {
namedPkgs = append(namedPkgs, p)
} else {
if header, ok := p.Metadata.(pkg.GGUFFileHeader); ok {
// We do not want a kv hash for nameless headers
header.MetadataKeyValuesHash = ""
namelessHeaders = append(namelessHeaders, header)
}
}
}
// If there are no named packages, return nothing
if len(namedPkgs) == 0 {
return nil, rels, err
}
// merge nameless headers into a single named package;
// if there are multiple named packages, return them without trying to merge headers.
// we cannot determine which nameless headers belong to which package
// this is because the order we receive the gguf headers in is not guaranteed
// to match the layer order in the original oci image
if len(namedPkgs) == 1 && len(namelessHeaders) > 0 {
winner := &namedPkgs[0]
if header, ok := winner.Metadata.(pkg.GGUFFileHeader); ok {
header.Parts = namelessHeaders
winner.Metadata = header
}
}
// Largest number of key value
return namedPkgs, rels, err
}
+63
View File
@@ -0,0 +1,63 @@
package ai
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/pkg"
)
func Test_ggufMergeProcessor(t *testing.T) {
tests := []struct {
name string
pkgs []pkg.Package
wantPkgCount int
wantFilePartCount int
}{
{
name: "single named package merges nameless headers",
pkgs: []pkg.Package{
{Name: "model", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "abc"}},
{Name: "", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "part1"}},
{Name: "", Metadata: pkg.GGUFFileHeader{MetadataKeyValuesHash: "part2"}},
},
wantPkgCount: 1,
wantFilePartCount: 2,
},
{
name: "multiple named packages returns all without merging",
pkgs: []pkg.Package{
{Name: "model1", Metadata: pkg.GGUFFileHeader{}},
{Name: "model2", Metadata: pkg.GGUFFileHeader{}},
{Name: "", Metadata: pkg.GGUFFileHeader{}},
},
wantPkgCount: 2,
wantFilePartCount: 0,
},
{
name: "no named packages returns empty result",
pkgs: []pkg.Package{
{Name: "", Metadata: pkg.GGUFFileHeader{}},
{Name: "", Metadata: pkg.GGUFFileHeader{}},
},
wantPkgCount: 0,
wantFilePartCount: 0,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, _, err := ggufMergeProcessor(test.pkgs, nil, nil)
require.NoError(t, err)
assert.Len(t, got, test.wantPkgCount)
if test.wantPkgCount == 1 && test.wantFilePartCount > 0 {
header, ok := got[0].Metadata.(pkg.GGUFFileHeader)
require.True(t, ok)
assert.Len(t, header.Parts, test.wantFilePartCount)
}
})
}
}
@@ -2164,6 +2164,11 @@ func (p *panicyResolver) FilesByMIMEType(_ ...string) ([]file.Location, error) {
return nil, errors.New("not implemented")
}
func (p *panicyResolver) FilesByMediaType(_ ...string) ([]file.Location, error) {
p.searchCalled = true
return nil, errors.New("not implemented")
}
func (p *panicyResolver) RelativeFileByPath(_ file.Location, _ string) *file.Location {
return nil
}
+20
View File
@@ -114,6 +114,26 @@ func (c *Cataloger) WithParserByPath(parser Parser, paths ...string) *Cataloger
return c
}
func (c *Cataloger) WithParserByMediaType(parser Parser, types ...string) *Cataloger {
c.requesters = append(c.requesters,
func(resolver file.Resolver, _ Environment) []request {
var requests []request
log.WithFields("mediatypes", types).Trace("searching content matching mediatypes")
ociResolver, ok := resolver.(file.OCIMediaTypeResolver)
if !ok {
return nil
}
matches, err := ociResolver.FilesByMediaType(types...)
if err != nil {
return nil
}
requests = append(requests, makeRequests(parser, matches)...)
return requests
},
)
return c
}
func (c *Cataloger) WithProcessors(processors ...Processor) *Cataloger {
for _, p := range processors {
c.processors = append(c.processors, processorWrapper{Processor: p})
@@ -138,6 +138,10 @@ func (m spyReturningFileResolver) FilesByMIMEType(types ...string) ([]file.Locat
return m.m.FilesByMIMEType(types...)
}
func (m spyReturningFileResolver) FilesByMediaType(types ...string) ([]file.Location, error) {
return m.m.FilesByMediaType(types...)
}
func (m spyReturningFileResolver) RelativeFileByPath(f file.Location, path string) *file.Location {
return m.m.RelativeFileByPath(f, path)
}
@@ -189,6 +193,55 @@ func TestClosesFileOnParserPanic(t *testing.T) {
require.True(t, spy.closed)
}
func Test_CatalogerWithParserByMediaType(t *testing.T) {
allParsedPaths := make(map[string]bool)
parser := func(_ context.Context, resolver file.Resolver, env *Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
allParsedPaths[reader.Path()] = true
contents, err := io.ReadAll(reader)
require.NoError(t, err)
if len(contents) == 0 {
return nil, nil, nil
}
p := pkg.Package{
Name: string(contents),
Locations: file.NewLocationSet(reader.Location),
}
return []pkg.Package{p}, nil, nil
}
upstream := "media-type-cataloger"
// Create locations with test fixtures that exist on disk
loc1 := file.NewLocation("test-fixtures/a-path.txt")
loc2 := file.NewLocation("test-fixtures/another-path.txt")
// Create a mock resolver that maps media types to locations
resolver := file.NewMockResolverForMediaTypes(map[string][]file.Location{
"application/vnd.test.model": {loc1, loc2},
})
cataloger := NewCataloger(upstream).
WithParserByMediaType(parser, "application/vnd.test.model")
actualPkgs, _, err := cataloger.Catalog(context.Background(), resolver)
assert.NoError(t, err)
// Verify both files were parsed
assert.True(t, allParsedPaths["test-fixtures/a-path.txt"], "expected a-path.txt to be parsed")
assert.True(t, allParsedPaths["test-fixtures/another-path.txt"], "expected another-path.txt to be parsed")
// Verify packages were created
assert.Len(t, actualPkgs, 2)
// Verify FoundBy is set correctly
for _, p := range actualPkgs {
assert.Equal(t, upstream, p.FoundBy)
}
}
func Test_genericCatalogerReturnsErrors(t *testing.T) {
genericErrorReturning := NewCataloger("error returning").WithParserByGlobs(func(ctx context.Context, resolver file.Resolver, environment *Environment, locationReader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
return []pkg.Package{
@@ -208,6 +208,11 @@ func (r *ObservingResolver) FilesByMIMEType(types ...string) ([]file.Location, e
return locs, err
}
// FilesByMediaType returns files matching the given media types.
func (r *ObservingResolver) FilesByMediaType(_ ...string) ([]file.Location, error) {
return nil, nil
}
// RelativeFileByPath returns a file at a path relative to the given location.
func (r *ObservingResolver) RelativeFileByPath(location file.Location, path string) *file.Location {
const methodName = "RelativeFileByPath"
@@ -80,6 +80,10 @@ func (r *rpmdbTestFileResolverMock) FilesByMIMEType(...string) ([]file.Location,
return nil, fmt.Errorf("not implemented")
}
func (r *rpmdbTestFileResolverMock) FilesByMediaType(...string) ([]file.Location, error) {
return nil, fmt.Errorf("not implemented")
}
func TestParseRpmDB(t *testing.T) {
ctx := context.TODO()
packagesLocation := file.NewLocation("test-fixtures/Packages")
+4
View File
@@ -34,4 +34,8 @@ type GGUFFileHeader struct {
// across different file locations or remotes. It allows matching identical models even
// when stored in different repositories or with different filenames.
MetadataKeyValuesHash string `json:"metadataHash,omitempty" cyclonedx:"metadataHash"`
// Parts contains headers from additional GGUF files that were merged
// into this package during post-processing (e.g., from OCI layers without model names).
Parts []GGUFFileHeader `json:"parts,omitempty" cyclonedx:"parts"`
}