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
@@ -35,6 +35,7 @@ const (
spdxPrimaryPurposeOther = "OTHER"
prefixImage = "Image"
prefixOCIModel = "OCIModel"
prefixDirectory = "Directory"
prefixFile = "File"
prefixSnap = "Snap"
@@ -215,6 +216,36 @@ func toRootPackage(s source.Description) *spdx.Package {
}
}
case source.OCIModelMetadata:
prefix = prefixOCIModel
purpose = spdxPrimaryPurposeContainer
qualifiers := packageurl.Qualifiers{
{
Key: "arch",
Value: m.Architecture,
},
}
ref, _ := reference.Parse(m.UserInput)
if ref, ok := ref.(reference.NamedTagged); ok {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "tag",
Value: ref.Tag(),
})
}
c := toChecksum(m.ManifestDigest)
if c != nil {
checksums = append(checksums, *c)
purl = &packageurl.PackageURL{
Type: "oci",
Name: s.Name,
Version: m.ManifestDigest,
Qualifiers: qualifiers,
}
}
case source.DirectoryMetadata:
prefix = prefixDirectory
purpose = spdxPrimaryPurposeFile
@@ -316,6 +316,81 @@ func Test_toFormatModel(t *testing.T) {
},
},
},
{
name: "oci-model",
in: sbom.SBOM{
Source: source.Description{
Name: "llama",
Version: "sha256:d34db33f",
Supplier: "Model Provider",
Metadata: source.OCIModelMetadata{
UserInput: "model-repo/llama:latest",
ManifestDigest: "sha256:d34db33f",
},
},
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(pkg.Package{
Name: "pkg-1",
Version: "version-1",
}),
},
},
expected: &spdx.Document{
SPDXIdentifier: "DOCUMENT",
SPDXVersion: spdx.Version,
DataLicense: spdx.DataLicense,
DocumentName: "llama",
Packages: []*spdx.Package{
{
PackageSPDXIdentifier: "Package-pkg-1-pkg-1",
PackageName: "pkg-1",
PackageVersion: "version-1",
PackageSupplier: &spdx.Supplier{
Supplier: "Model Provider",
SupplierType: "Organization",
},
},
{
PackageSPDXIdentifier: "DocumentRoot-OCIModel-llama",
PackageName: "llama",
PackageVersion: "sha256:d34db33f",
PrimaryPackagePurpose: "CONTAINER",
PackageChecksums: []spdx.Checksum{{Algorithm: "SHA256", Value: "d34db33f"}},
PackageExternalReferences: []*v2_3.PackageExternalReference{
{
Category: "PACKAGE-MANAGER",
RefType: "purl",
Locator: "pkg:oci/llama@sha256%3Ad34db33f?arch=&tag=latest",
},
},
PackageSupplier: &spdx.Supplier{
Supplier: "Model Provider",
SupplierType: "Organization",
},
},
},
Relationships: []*spdx.Relationship{
{
RefA: spdx.DocElementID{
ElementRefID: "DocumentRoot-OCIModel-llama",
},
RefB: spdx.DocElementID{
ElementRefID: "Package-pkg-1-pkg-1",
},
Relationship: spdx.RelationshipContains,
},
{
RefA: spdx.DocElementID{
ElementRefID: "DOCUMENT",
},
RefB: spdx.DocElementID{
ElementRefID: "DocumentRoot-OCIModel-llama",
},
Relationship: spdx.RelationshipDescribes,
},
},
},
},
}
for _, test := range tests {
@@ -122,6 +122,9 @@ func toPath(s source.Description, p pkg.Package) string {
case source.ImageMetadata:
image := strings.ReplaceAll(metadata.UserInput, ":/", "//")
return fmt.Sprintf("%s:/%s", image, packagePath)
case source.OCIModelMetadata:
image := strings.ReplaceAll(metadata.UserInput, ":/", "//")
return fmt.Sprintf("%s:/%s", image, packagePath)
case source.FileMetadata:
path := trimRelative(metadata.Path)
if isArchive(metadata.Path) {
@@ -178,6 +178,11 @@ func Test_toGithubModel(t *testing.T) {
metadata: source.SnapMetadata{},
testPath: "name:/etc",
},
{
name: "oci-model",
metadata: source.OCIModelMetadata{UserInput: "model-repo/llama:latest"},
testPath: "model-repo/llama:latest:/etc",
},
}
for _, test := range tests {
@@ -12,6 +12,8 @@ func DocumentName(src source.Description) string {
switch metadata := src.Metadata.(type) {
case source.ImageMetadata:
return metadata.UserInput
case source.OCIModelMetadata:
return metadata.UserInput
case source.DirectoryMetadata:
return metadata.Path
case source.FileMetadata:
@@ -54,6 +54,17 @@ func Test_DocumentName(t *testing.T) {
},
expected: "some/name",
},
{
name: "oci-model",
srcMetadata: source.Description{
Metadata: source.OCIModelMetadata{
UserInput: "model-repo/name:tag",
ID: "id",
ManifestDigest: "digest",
},
},
expected: "model-repo/name:tag",
},
{
name: "named",
srcMetadata: source.Description{
@@ -14,6 +14,7 @@ import (
const (
InputImage = "image"
InputOCIModel = "oci-model"
InputDirectory = "dir"
InputFile = "file"
InputSnap = "snap"
@@ -30,6 +31,8 @@ func DocumentNamespace(name string, src source.Description, desc sbom.Descriptor
switch src.Metadata.(type) {
case source.ImageMetadata:
input = InputImage
case source.OCIModelMetadata:
input = InputOCIModel
case source.DirectoryMetadata:
input = InputDirectory
case source.FileMetadata:
@@ -61,6 +61,18 @@ func Test_DocumentNamespace(t *testing.T) {
},
expected: "https://anchore.com/syft/snap/my-name-",
},
{
name: "oci-model",
inputName: "my-name",
src: source.Description{
Metadata: source.OCIModelMetadata{
UserInput: "model-repo/name:tag",
ID: "id",
ManifestDigest: "digest",
},
},
expected: "https://anchore.com/syft/oci-model/my-name-",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -83,7 +83,7 @@ func SourceInfo(p pkg.Package) string {
case pkg.TerraformPkg:
answer = "acquired package info from Terraform dependency lock file"
case pkg.ModelPkg:
answer = "acquired package info from AI artifact (e.g. GGUF File"
answer = "acquired package info from AI artifact (e.g. GGUF File)"
default:
answer = "acquired package info from the following paths"
}
+31
View File
@@ -190,6 +190,37 @@ func TestSource_UnmarshalJSON(t *testing.T) {
},
},
},
{
name: "oci-model",
input: []byte(`{
"id": "foobar",
"type": "oci-model",
"metadata": {
"userInput": "model-repo/llama:latest",
"imageID": "sha256:e7b300aee9f9bf3433d32bc9305bfdd22183beb59d933b48d77ab56ba53a197a",
"manifestDigest": "sha256:e515aad2ed234a5072c4d2ef86a1cb77d5bfe4b11aa865d9214875734c4eeb3c",
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"tags": [],
"imageSize": 5576169,
"layers": [],
"repoDigests": []
}
}`),
expected: &Source{
ID: "foobar",
Type: "oci-model",
Metadata: source.OCIModelMetadata{
UserInput: "model-repo/llama:latest",
ID: "sha256:e7b300aee9f9bf3433d32bc9305bfdd22183beb59d933b48d77ab56ba53a197a",
ManifestDigest: "sha256:e515aad2ed234a5072c4d2ef86a1cb77d5bfe4b11aa865d9214875734c4eeb3c",
MediaType: "application/vnd.oci.image.manifest.v1+json",
Tags: []string{},
Size: 5576169,
Layers: []source.LayerMetadata{},
RepoDigests: []string{},
},
},
},
{
name: "unknown source type",
input: []byte(`{
+11 -1
View File
@@ -325,7 +325,17 @@ func toSourceModel(src source.Description) model.Source {
Metadata: src.Metadata,
}
if metadata, ok := src.Metadata.(source.ImageMetadata); ok {
switch metadata := src.Metadata.(type) {
case source.ImageMetadata:
// ensure that empty collections are not shown as null
if metadata.RepoDigests == nil {
metadata.RepoDigests = []string{}
}
if metadata.Tags == nil {
metadata.Tags = []string{}
}
m.Metadata = metadata
case source.OCIModelMetadata:
// ensure that empty collections are not shown as null
if metadata.RepoDigests == nil {
metadata.RepoDigests = []string{}
@@ -161,6 +161,34 @@ func Test_toSourceModel(t *testing.T) {
},
},
},
{
name: "oci-model",
src: source.Description{
ID: "test-id",
Name: "some-name",
Version: "some-version",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
},
},
expected: model.Source{
ID: "test-id",
Name: "some-name",
Version: "some-version",
Type: "oci-model",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
RepoDigests: []string{},
Tags: []string{},
},
},
},
// below are regression tests for when the name/version are not provided
// historically we've hoisted up the name/version from the metadata, now it is a simple pass-through
{
@@ -225,6 +253,30 @@ func Test_toSourceModel(t *testing.T) {
},
},
},
{
name: "oci-model - no name/version",
src: source.Description{
ID: "test-id",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
},
},
expected: model.Source{
ID: "test-id",
Type: "oci-model",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
RepoDigests: []string{},
Tags: []string{},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -130,6 +130,32 @@ func Test_toSyftSourceData(t *testing.T) {
},
},
},
{
name: "oci-model",
src: model.Source{
ID: "the-id",
Name: "some-name",
Version: "some-version",
Type: "oci-model",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
},
},
expected: &source.Description{
ID: "the-id",
Name: "some-name",
Version: "some-version",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
},
},
},
// below are regression tests for when the name/version are not provided
// historically we've hoisted up the name/version from the metadata, now it is a simple pass-through
{
@@ -192,6 +218,28 @@ func Test_toSyftSourceData(t *testing.T) {
},
},
},
{
name: "oci-model - no name/version",
src: model.Source{
ID: "the-id",
Type: "oci-model",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
},
},
expected: &source.Description{
ID: "the-id",
Metadata: source.OCIModelMetadata{
UserInput: "user-input",
ID: "id...",
ManifestDigest: "digest...",
MediaType: "type...",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {