feat(cataloger): add snap package cataloger for metadata extraction (#4151)

---------
Signed-off-by: Alan Pope <alan.pope@anchore.com>
Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
This commit is contained in:
Alan Pope 2025-09-26 15:42:29 +01:00 committed by GitHub
parent 64b71ec04c
commit 0a36dabf23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 4417 additions and 3 deletions

2
go.mod
View File

@ -281,7 +281,7 @@ require (
google.golang.org/grpc v1.67.3 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gopkg.in/yaml.v3 v3.0.1
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

View File

@ -3,5 +3,5 @@ package internal
const (
// JSONSchemaVersion is the current schema version output by the JSON encoder
// This is roughly following the "SchemaVer" guidelines for versioning the JSON schema. Please see schema/json/README.md for details on how to increment.
JSONSchemaVersion = "16.0.39"
JSONSchemaVersion = "16.0.40"
)

View File

@ -32,6 +32,7 @@ import (
"github.com/anchore/syft/syft/pkg/cataloger/ruby"
"github.com/anchore/syft/syft/pkg/cataloger/rust"
sbomCataloger "github.com/anchore/syft/syft/pkg/cataloger/sbom"
"github.com/anchore/syft/syft/pkg/cataloger/snap"
"github.com/anchore/syft/syft/pkg/cataloger/swift"
"github.com/anchore/syft/syft/pkg/cataloger/swipl"
"github.com/anchore/syft/syft/pkg/cataloger/terraform"
@ -173,6 +174,7 @@ func DefaultPackageTaskFactories() Factories {
newSimplePackageTaskFactory(terraform.NewLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, "terraform"),
newSimplePackageTaskFactory(homebrew.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "homebrew"),
newSimplePackageTaskFactory(conda.NewCondaMetaCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.PackageTag, "conda"),
newSimplePackageTaskFactory(snap.NewCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, "snap"),
// deprecated catalogers ////////////////////////////////////////
// these are catalogers that should not be selectable other than specific inclusion via name or "deprecated" tag (to remain backwards compatible)

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "anchore.io/schema/syft/json/16.0.39/document",
"$id": "anchore.io/schema/syft/json/16.0.40/document",
"$ref": "#/$defs/Document",
"$defs": {
"AlpmDbEntry": {
@ -2194,6 +2194,9 @@
{
"$ref": "#/$defs/RustCargoLockEntry"
},
{
"$ref": "#/$defs/SnapEntry"
},
{
"$ref": "#/$defs/SwiftPackageManagerLockEntry"
},
@ -3205,6 +3208,33 @@
"url"
]
},
"SnapEntry": {
"properties": {
"snapType": {
"type": "string"
},
"base": {
"type": "string"
},
"snapName": {
"type": "string"
},
"snapVersion": {
"type": "string"
},
"architecture": {
"type": "string"
}
},
"type": "object",
"required": [
"snapType",
"base",
"snapName",
"snapVersion",
"architecture"
]
},
"Source": {
"properties": {
"id": {

View File

@ -47,6 +47,7 @@ func Test_OriginatorSupplier(t *testing.T) {
pkg.PythonUvLockEntry{},
pkg.RustBinaryAuditEntry{},
pkg.RustCargoLockEntry{},
pkg.SnapEntry{},
pkg.SwiftPackageManagerResolvedEntry{},
pkg.SwiplPackEntry{},
pkg.OpamPackage{},

View File

@ -61,6 +61,7 @@ func AllTypes() []any {
pkg.RubyGemspec{},
pkg.RustBinaryAuditEntry{},
pkg.RustCargoLockEntry{},
pkg.SnapEntry{},
pkg.SwiftPackageManagerResolvedEntry{},
pkg.SwiplPackEntry{},
pkg.TerraformLockProviderEntry{},

View File

@ -115,6 +115,7 @@ var jsonTypes = makeJSONTypes(
jsonNames(pkg.OpamPackage{}, "opam-package"),
jsonNames(pkg.RustCargoLockEntry{}, "rust-cargo-lock-entry", "RustCargoPackageMetadata"),
jsonNamesWithoutLookup(pkg.RustBinaryAuditEntry{}, "rust-cargo-audit-entry", "RustCargoPackageMetadata"), // the legacy value is split into two types, where the other is preferred
jsonNames(pkg.SnapEntry{}, "snap-entry"),
jsonNames(pkg.WordpressPluginEntry{}, "wordpress-plugin-entry", "WordpressMetadata"),
jsonNames(pkg.HomebrewFormula{}, "homebrew-formula"),
jsonNames(pkg.LuaRocksPackage{}, "luarocks-package"),

View File

@ -0,0 +1,28 @@
/*
Package snap provides a concrete Cataloger implementation for snap packages, extracting metadata
from different types of snap files (base, kernel, system/gadget, snapd) rather than just scanning
the filesystem.
*/
package snap
import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
const catalogerName = "snap-cataloger"
// NewCataloger returns a new Snap cataloger object that can parse snap package metadata.
func NewCataloger() pkg.Cataloger {
return generic.NewCataloger(catalogerName).
// Look for snap.yaml to identify snap type and base snap info
WithParserByGlobs(parseSnapYaml, "**/meta/snap.yaml").
// Base snaps: dpkg.yaml files containing package manifests
WithParserByGlobs(parseBaseDpkgYaml, "**/usr/share/snappy/dpkg.yaml").
// Kernel snaps: changelog files for kernel version info
WithParserByGlobs(parseKernelChangelog, "**/doc/linux-modules-*/changelog.Debian.gz").
// System/Gadget snaps: manifest files with primed-stage-packages
WithParserByGlobs(parseSystemManifest, "**/snap/manifest.yaml").
// Snapd snaps: snapcraft.yaml files
WithParserByGlobs(parseSnapdSnapcraft, "**/snap/snapcraft.yaml")
}

View File

@ -0,0 +1,36 @@
package snap
import (
"testing"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestCataloger_Globs(t *testing.T) {
tests := []struct {
name string
fixture string
}{
{
name: "base snap with dpkg.yaml",
fixture: "test-fixtures/glob-paths/base",
},
{
name: "system snap with manifest.yaml",
fixture: "test-fixtures/glob-paths/system",
},
{
name: "snap with meta/snap.yaml",
fixture: "test-fixtures/glob-paths/meta",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
pkgtest.NewCatalogTester().
FromDirectory(t, test.fixture).
IgnoreUnfulfilledPathResponses("**/meta/snap.yaml", "**/usr/share/snappy/dpkg.yaml", "**/doc/linux-modules-*/changelog.Debian.gz", "**/snap/manifest.yaml", "**/snap/snapcraft.yaml").
TestCataloger(t, NewCataloger())
})
}
}

View File

@ -0,0 +1,55 @@
package snap
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
func TestRealDpkgYamlParsing(t *testing.T) {
fixture := "test-fixtures/real-dpkg.yaml"
// Open the file
f, err := os.Open(fixture)
require.NoError(t, err)
defer f.Close()
reader := file.LocationReadCloser{
Location: file.NewLocation(fixture),
ReadCloser: f,
}
// Parse using our function
packages, relationships, err := parseBaseDpkgYaml(context.Background(), nil, &generic.Environment{}, reader)
require.NoError(t, err)
assert.Nil(t, relationships) // relationships should be nil for this parser
// We should have 10 packages from the fixture
assert.Equal(t, 10, len(packages))
// Check some specific packages
foundPackages := make(map[string]pkg.Package)
for _, p := range packages {
foundPackages[p.Name] = p
}
// Verify key packages exist
require.Contains(t, foundPackages, "adduser")
require.Contains(t, foundPackages, "systemd")
require.Contains(t, foundPackages, "gcc-10-base")
// Check that architecture is parsed correctly from package names
gccPkg := foundPackages["gcc-10-base"]
metadata, ok := gccPkg.Metadata.(pkg.SnapEntry)
require.True(t, ok)
assert.Equal(t, "amd64", metadata.Architecture)
assert.Equal(t, pkg.SnapTypeBase, metadata.SnapType)
}

View File

@ -0,0 +1,94 @@
package snap
import (
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
)
// newPackage creates a new Package from snap metadata
func newPackage(name, version string, metadata pkg.SnapEntry, locations ...file.Location) pkg.Package {
p := pkg.Package{
Name: name,
Version: version,
Locations: file.NewLocationSet(locations...),
PURL: packageURL(name, version, metadata),
Type: pkg.DebPkg, // Use Debian package type for compatibility
Metadata: metadata,
}
p.SetID()
return p
}
// packageURL returns the PURL for a snap package
func packageURL(name, version string, metadata pkg.SnapEntry) string {
var qualifiers packageurl.Qualifiers
if metadata.Architecture != "" {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "arch",
Value: metadata.Architecture,
})
}
if metadata.Base != "" {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "base",
Value: metadata.Base,
})
}
if metadata.SnapType != "" {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "type",
Value: metadata.SnapType,
})
}
return packageurl.NewPackageURL(
packageurl.TypeGeneric,
"snap",
name,
version,
qualifiers,
"",
).ToString()
}
// newDebianPackageFromSnap creates a Debian-style package entry from snap manifest data
func newDebianPackageFromSnap(name, version string, snapMetadata pkg.SnapEntry, locations ...file.Location) pkg.Package {
p := pkg.Package{
Name: name,
Version: version,
Locations: file.NewLocationSet(locations...),
Type: pkg.DebPkg,
PURL: debianPackageURL(name, version, snapMetadata.Architecture),
Metadata: snapMetadata,
}
p.SetID()
return p
}
// debianPackageURL creates a PURL for Debian packages found in snaps
func debianPackageURL(name, version, architecture string) string {
var qualifiers packageurl.Qualifiers
if architecture != "" {
qualifiers = append(qualifiers, packageurl.Qualifier{
Key: "arch",
Value: architecture,
})
}
return packageurl.NewPackageURL(
packageurl.TypeDebian,
"ubuntu", // Assume Ubuntu since most snaps are built on Ubuntu
name,
version,
qualifiers,
"",
).ToString()
}

View File

@ -0,0 +1,80 @@
package snap
import (
"context"
"fmt"
"strings"
"gopkg.in/yaml.v3"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// dpkgYaml represents the structure of dpkg.yaml files found in base snaps
type dpkgYaml struct {
PackageRepositories []packageRepository `yaml:"package-repositories"`
Packages []string `yaml:"packages"`
}
type packageRepository struct {
Type string `yaml:"type"`
PPA string `yaml:"ppa,omitempty"`
URL string `yaml:"url,omitempty"`
}
// parseBaseDpkgYaml parses dpkg.yaml files from base snaps
func parseBaseDpkgYaml(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var dpkg dpkgYaml
decoder := yaml.NewDecoder(reader)
if err := decoder.Decode(&dpkg); err != nil {
return nil, nil, fmt.Errorf("failed to parse dpkg.yaml: %w", err)
}
var packages []pkg.Package
snapMetadata := pkg.SnapEntry{
SnapType: pkg.SnapTypeBase,
}
// Parse each package entry in "name=version" format
for _, pkgEntry := range dpkg.Packages {
if !strings.Contains(pkgEntry, "=") {
continue // Skip malformed entries
}
parts := strings.SplitN(pkgEntry, "=", 2)
if len(parts) != 2 {
continue
}
name := strings.TrimSpace(parts[0])
version := strings.TrimSpace(parts[1])
// Skip empty names or versions
if name == "" || version == "" {
continue
}
// Handle architecture suffixes (e.g., "libssl1.1:amd64")
if strings.Contains(name, ":") {
archParts := strings.SplitN(name, ":", 2)
name = archParts[0]
snapMetadata.Architecture = archParts[1]
}
debPkg := newDebianPackageFromSnap(
name,
version,
snapMetadata,
reader.Location,
)
packages = append(packages, debPkg)
}
return packages, nil, nil
}

View File

@ -0,0 +1,50 @@
package snap
import (
"testing"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestParseBaseDpkgYaml(t *testing.T) {
fixture := "test-fixtures/dpkg.yaml"
locations := file.NewLocationSet(file.NewLocation(fixture))
expected := []pkg.Package{
{
Name: "adduser",
Version: "3.118ubuntu2",
Type: pkg.DebPkg,
PURL: "pkg:deb/ubuntu/adduser@3.118ubuntu2",
Locations: locations,
Metadata: pkg.SnapEntry{
SnapType: pkg.SnapTypeBase,
},
},
{
Name: "apparmor",
Version: "2.13.3-7ubuntu5.3",
Type: pkg.DebPkg,
PURL: "pkg:deb/ubuntu/apparmor@2.13.3-7ubuntu5.3",
Locations: locations,
Metadata: pkg.SnapEntry{
SnapType: pkg.SnapTypeBase,
},
},
{
Name: "gcc-10-base",
Version: "10.5.0-1ubuntu1~20.04",
Type: pkg.DebPkg,
PURL: "pkg:deb/ubuntu/gcc-10-base@10.5.0-1ubuntu1~20.04?arch=amd64",
Locations: locations,
Metadata: pkg.SnapEntry{
SnapType: pkg.SnapTypeBase,
Architecture: "amd64", // from package name suffix
},
},
}
pkgtest.TestFileParser(t, fixture, parseBaseDpkgYaml, expected, nil)
}

View File

@ -0,0 +1,71 @@
package snap
import (
"testing"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestParseSnapYaml(t *testing.T) {
fixture := "test-fixtures/snap.yaml"
locations := file.NewLocationSet(file.NewLocation(fixture))
expected := []pkg.Package{
{
Name: "test-snap",
Version: "1.0.0",
Type: pkg.DebPkg,
PURL: "pkg:generic/snap/test-snap@1.0.0?arch=amd64&base=core20&type=app",
Locations: locations,
Metadata: pkg.SnapEntry{
SnapType: pkg.SnapTypeApp,
Base: "core20",
SnapName: "test-snap",
SnapVersion: "1.0.0",
Architecture: "amd64",
},
},
}
pkgtest.TestFileParser(t, fixture, parseSnapYaml, expected, nil)
}
func TestParseSystemManifest(t *testing.T) {
fixture := "test-fixtures/manifest.yaml"
locations := file.NewLocationSet(file.NewLocation(fixture))
expected := []pkg.Package{
{
Name: "grub-efi-amd64-signed",
Version: "1.202+2.12-1ubuntu7",
Type: pkg.DebPkg,
PURL: "pkg:deb/ubuntu/grub-efi-amd64-signed@1.202%2B2.12-1ubuntu7?arch=amd64", // URL encoded
Locations: locations,
Metadata: pkg.SnapEntry{
SnapType: pkg.SnapTypeApp, // Default type when gadget not detected from name
Base: "core24",
SnapName: "pc",
SnapVersion: "24-0.1",
Architecture: "amd64", // From architectures array
},
},
{
Name: "shim-signed",
Version: "1.56+15.7-0ubuntu1",
Type: pkg.DebPkg,
PURL: "pkg:deb/ubuntu/shim-signed@1.56%2B15.7-0ubuntu1?arch=amd64", // URL encoded
Locations: locations,
Metadata: pkg.SnapEntry{
SnapType: pkg.SnapTypeApp, // Default type when gadget not detected from name
Base: "core24",
SnapName: "pc",
SnapVersion: "24-0.1",
Architecture: "amd64", // From architectures array
},
},
}
pkgtest.TestFileParser(t, fixture, parseSystemManifest, expected, nil)
}

View File

@ -0,0 +1,160 @@
package snap
import (
"compress/gzip"
"context"
"fmt"
"regexp"
"strings"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// kernelVersionInfo holds parsed kernel version information
type kernelVersionInfo struct {
baseVersion string // e.g., "5.4.0-195"
releaseVersion string // e.g., "215"
fullVersion string // e.g., "5.4.0-195.215"
majorVersion string // e.g., "5.4"
}
// parseKernelChangelog parses changelog files from kernel snaps to extract kernel version
func parseKernelChangelog(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
// The file should be gzipped
lines, err := readChangelogLines(reader)
if err != nil {
return nil, nil, err
}
// pull from first line
versionInfo, err := extractKernelVersion(lines[0])
if err != nil {
return nil, nil, err
}
snapMetadata := pkg.SnapEntry{
SnapType: pkg.SnapTypeKernel,
}
packages := createMainKernelPackage(versionInfo, snapMetadata, reader.Location)
// Check for base kernel package
basePackage := findBaseKernelPackage(lines, versionInfo, snapMetadata, reader.Location)
if basePackage != nil {
packages = append(packages, *basePackage)
}
return packages, nil, nil
}
// readChangelogLines reads and decompresses the changelog content
func readChangelogLines(reader file.LocationReadCloser) ([]string, error) {
gzReader, err := gzip.NewReader(reader)
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader for changelog: %w", err)
}
defer gzReader.Close()
content, err := readAll(gzReader)
if err != nil {
return nil, fmt.Errorf("failed to read changelog content: %w", err)
}
lines := strings.Split(string(content), "\n")
if len(lines) == 0 {
return nil, fmt.Errorf("changelog file is empty")
}
// Parse the first line to extract kernel version information
// Format: "linux (5.4.0-195.215) focal; urgency=medium"
return lines, nil
}
// extractKernelVersion parses version information from the first changelog line
func extractKernelVersion(firstLine string) (*kernelVersionInfo, error) {
// Format: "linux (5.4.0-195.215) focal; urgency=medium"
kernelVersionRegex := regexp.MustCompile(`linux \(([0-9]+\.[0-9]+\.[0-9]+-[0-9]+)\.([0-9]+)\)`)
matches := kernelVersionRegex.FindStringSubmatch(firstLine)
if len(matches) < 3 {
return nil, fmt.Errorf("could not parse kernel version from changelog: %s", firstLine)
}
info := &kernelVersionInfo{
baseVersion: matches[1], // e.g., "5.4.0-195"
releaseVersion: matches[2], // eg., "215"
}
// eg "5.4.0-195.215"
info.fullVersion = fmt.Sprintf("%s.%s", info.baseVersion, info.releaseVersion)
// Extract major version; package naming
majorVersionRegex := regexp.MustCompile(`([0-9]+\.[0-9]+)\.[0-9]+-[0-9]+`)
majorMatches := majorVersionRegex.FindStringSubmatch(info.baseVersion)
if len(majorMatches) >= 2 {
info.majorVersion = majorMatches[1]
} else {
info.majorVersion = info.baseVersion
}
return info, nil
}
// createMainKernelPackage creates the main kernel package
func createMainKernelPackage(versionInfo *kernelVersionInfo, snapMetadata pkg.SnapEntry, location file.Location) []pkg.Package {
kernelPackageName := fmt.Sprintf("linux-image-%s-generic", versionInfo.baseVersion)
kernelPkg := newDebianPackageFromSnap(
kernelPackageName,
versionInfo.fullVersion,
snapMetadata,
location,
)
return []pkg.Package{kernelPkg}
}
// findBaseKernelPackage searches for and creates base kernel package if present
func findBaseKernelPackage(lines []string, versionInfo *kernelVersionInfo, snapMetadata pkg.SnapEntry, location file.Location) *pkg.Package {
baseKernelEntry := fmt.Sprintf("%s/linux:", strings.ReplaceAll(versionInfo.releaseVersion, ";", "/"))
for _, line := range lines {
if strings.Contains(line, baseKernelEntry) {
return parseBaseKernelLine(line, versionInfo.majorVersion, snapMetadata, location)
}
}
return nil
}
// parseBaseKernelLine extracts base kernel version from a changelog line
func parseBaseKernelLine(line string, majorVersion string, snapMetadata pkg.SnapEntry, location file.Location) *pkg.Package {
baseKernelRegex := regexp.MustCompile(fmt.Sprintf(`(%s-[0-9]+)\.?[0-9]*`, regexp.QuoteMeta(majorVersion)))
baseMatches := baseKernelRegex.FindStringSubmatch(line)
if len(baseMatches) < 2 {
return nil
}
baseKernelVersion := baseMatches[1]
baseKernelFullRegex := regexp.MustCompile(fmt.Sprintf(`(%s-[0-9]+\.[0-9]+)`, regexp.QuoteMeta(majorVersion)))
baseFullMatches := baseKernelFullRegex.FindStringSubmatch(line)
var baseFullVersion string
if len(baseFullMatches) >= 2 {
baseFullVersion = baseFullMatches[1]
} else {
baseFullVersion = baseKernelVersion
}
baseKernelPkg := newDebianPackageFromSnap(
fmt.Sprintf("linux-image-%s-generic", baseKernelVersion),
baseFullVersion,
snapMetadata,
location,
)
return &baseKernelPkg
}

View File

@ -0,0 +1,68 @@
package snap
import (
"context"
"fmt"
"io"
"gopkg.in/yaml.v3"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// snapYaml represents the structure of meta/snap.yaml files
type snapYaml struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Base string `yaml:"base"`
Type string `yaml:"type"`
Architecture string `yaml:"architecture"`
Summary string `yaml:"summary"`
Description string `yaml:"description"`
}
// parseSnapYaml parses meta/snap.yaml files to identify snap type and basic metadata
func parseSnapYaml(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var snap snapYaml
decoder := yaml.NewDecoder(reader)
if err := decoder.Decode(&snap); err != nil {
return nil, nil, fmt.Errorf("failed to parse snap.yaml: %w", err)
}
if snap.Name == "" {
return nil, nil, fmt.Errorf("snap.yaml missing required 'name' field")
}
// Determine snap type - default to "app" if not specified
snapType := snap.Type
if snapType == "" {
snapType = pkg.SnapTypeApp
}
metadata := pkg.SnapEntry{
SnapType: snapType,
Base: snap.Base,
SnapName: snap.Name,
SnapVersion: snap.Version,
Architecture: snap.Architecture,
}
// Create a package representing the snap itself
snapPkg := newPackage(
snap.Name,
snap.Version,
metadata,
reader.Location,
)
return []pkg.Package{snapPkg}, nil, nil
}
// readAll reads all content from a reader and returns it as bytes
func readAll(r io.Reader) ([]byte, error) {
return io.ReadAll(r)
}

View File

@ -0,0 +1,188 @@
package snap
import (
"context"
"fmt"
"strings"
"gopkg.in/yaml.v3"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// snapcraftYaml represents the structure of snapcraft.yaml files found in snapd snaps
type snapcraftYaml struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Summary string `yaml:"summary"`
Description string `yaml:"description"`
Base string `yaml:"base"`
Grade string `yaml:"grade"`
Confinement string `yaml:"confinement"`
Architectures []string `yaml:"architectures"`
Parts map[string]snapcraftPart `yaml:"parts"`
}
// snapcraftPart represents a part in a snapcraft.yaml file
type snapcraftPart struct {
Plugin string `yaml:"plugin"`
Source string `yaml:"source"`
SourceType string `yaml:"source-type"`
SourceTag string `yaml:"source-tag"`
SourceCommit string `yaml:"source-commit"`
BuildPackages []string `yaml:"build-packages"`
StagePackages []string `yaml:"stage-packages"`
BuildSnaps []string `yaml:"build-snaps"`
StageSnaps []string `yaml:"stage-snaps"`
BuildEnvironment []map[string]string `yaml:"build-environment"`
Override map[string]string `yaml:"override-build"`
}
// parseSnapdSnapcraft parses snapcraft.yaml files from snapd snaps
func parseSnapdSnapcraft(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var snapcraft snapcraftYaml
decoder := yaml.NewDecoder(reader)
if err := decoder.Decode(&snapcraft); err != nil {
return nil, nil, fmt.Errorf("failed to parse snapcraft.yaml: %w", err)
}
snapMetadata := createMetadata(snapcraft)
packages := extractPackagesFromParts(snapcraft, snapMetadata, reader.Location)
return packages, nil, nil
}
// createMetadata creates metadata from snapcraft.yaml
func createMetadata(snapcraft snapcraftYaml) pkg.SnapEntry {
metadata := pkg.SnapEntry{
SnapType: pkg.SnapTypeSnapd,
Base: snapcraft.Base,
SnapName: snapcraft.Name,
SnapVersion: snapcraft.Version,
}
if len(snapcraft.Architectures) > 0 {
metadata.Architecture = snapcraft.Architectures[0]
}
return metadata
}
// extractPackagesFromParts processes all parts to extract packages
func extractPackagesFromParts(snapcraft snapcraftYaml, baseMetadata pkg.SnapEntry, location file.Location) []pkg.Package {
var packages []pkg.Package
for _, part := range snapcraft.Parts {
buildPackages := processBuildPackages(part.BuildPackages, baseMetadata, location)
packages = append(packages, buildPackages...)
stagePackages := processStagePackages(part.StagePackages, baseMetadata, location)
packages = append(packages, stagePackages...)
snapPackages := processSnapPackages(part.BuildSnaps, part.StageSnaps, baseMetadata, location)
packages = append(packages, snapPackages...)
}
return packages
}
// processBuildPackages creates packages from build-packages list
func processBuildPackages(buildPackages []string, metadata pkg.SnapEntry, location file.Location) []pkg.Package {
var packages []pkg.Package
for _, pkgName := range buildPackages {
if pkgName == "" {
continue
}
buildPkg := newDebianPackageFromSnap(
pkgName,
"unknown",
metadata,
location,
)
packages = append(packages, buildPkg)
}
return packages
}
// processStagePackages creates packages from stage-packages list with version parsing
func processStagePackages(stagePackages []string, metadata pkg.SnapEntry, location file.Location) []pkg.Package {
var packages []pkg.Package
for _, pkgEntry := range stagePackages {
if pkgEntry == "" {
continue
}
name, version := parsePackageWithVersion(pkgEntry)
stagePkg := newDebianPackageFromSnap(
name,
version,
metadata,
location,
)
packages = append(packages, stagePkg)
}
return packages
}
// parsePackageWithVersion extracts package name and version from version-constrained entries
func parsePackageWithVersion(pkgEntry string) (string, string) {
name := pkgEntry
version := "unknown"
if !strings.ContainsAny(pkgEntry, "=<>") {
return name, version
}
// Try to split on version operators
operators := []string{">=", "<=", "==", "!=", "=", ">", "<"}
for _, op := range operators {
if strings.Contains(pkgEntry, op) {
parts := strings.SplitN(pkgEntry, op, 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
}
}
}
return name, version
}
// processSnapPackages creates packages from snap dependencies
func processSnapPackages(buildSnaps, stageSnaps []string, baseMetadata pkg.SnapEntry, location file.Location) []pkg.Package {
var packages []pkg.Package
allSnaps := make([]string, 0, len(buildSnaps)+len(stageSnaps))
allSnaps = append(allSnaps, buildSnaps...)
allSnaps = append(allSnaps, stageSnaps...)
for _, snapName := range allSnaps {
if snapName == "" {
continue
}
snapMetadata := pkg.SnapEntry{
SnapType: pkg.SnapTypeApp,
SnapName: snapName,
SnapVersion: "unknown",
Architecture: baseMetadata.Architecture,
}
snapPkg := newPackage(
snapName,
"unknown",
snapMetadata,
location,
)
packages = append(packages, snapPkg)
}
return packages
}

View File

@ -0,0 +1,102 @@
package snap
import (
"context"
"fmt"
"strings"
"gopkg.in/yaml.v3"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
// systemManifest represents the structure of manifest.yaml files found in system/gadget snaps
type systemManifest struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Base string `yaml:"base"`
Grade string `yaml:"grade"`
Confinement string `yaml:"confinement"`
PrimedStagePackages []string `yaml:"primed-stage-packages"`
Architectures []string `yaml:"architectures"`
SnapcraftVersion string `yaml:"snapcraft-version"`
SnapcraftOSReleaseID string `yaml:"snapcraft-os-release-id"`
}
// parseSystemManifest parses manifest.yaml files from system/gadget snaps
func parseSystemManifest(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var manifest systemManifest
decoder := yaml.NewDecoder(reader)
if err := decoder.Decode(&manifest); err != nil {
return nil, nil, fmt.Errorf("failed to parse manifest.yaml: %w", err)
}
var packages []pkg.Package
// Determine snap type - could be system, gadget, or app
snapType := pkg.SnapTypeApp // Default
if manifest.Name != "" {
// Try to infer type from name patterns or content
switch {
case strings.Contains(strings.ToLower(manifest.Name), "gadget"):
snapType = pkg.SnapTypeGadget
default:
snapType = pkg.SnapTypeApp // System snaps are often just regular apps
}
}
snapMetadata := pkg.SnapEntry{
SnapType: snapType,
Base: manifest.Base,
SnapName: manifest.Name,
SnapVersion: manifest.Version,
}
// Set architecture if available
if len(manifest.Architectures) > 0 {
snapMetadata.Architecture = manifest.Architectures[0]
}
// Parse primed-stage-packages entries
for _, pkgEntry := range manifest.PrimedStagePackages {
if !strings.Contains(pkgEntry, "=") {
continue // Skip malformed entries
}
parts := strings.SplitN(pkgEntry, "=", 2)
if len(parts) != 2 {
continue
}
name := strings.TrimSpace(parts[0])
version := strings.TrimSpace(parts[1])
// Skip empty names or versions
if name == "" || version == "" {
continue
}
// Handle architecture suffixes if present
currentMetadata := snapMetadata
if strings.Contains(name, ":") {
archParts := strings.SplitN(name, ":", 2)
name = archParts[0]
currentMetadata.Architecture = archParts[1]
}
debPkg := newDebianPackageFromSnap(
name,
version,
currentMetadata,
reader.Location,
)
packages = append(packages, debPkg)
}
return packages, nil, nil
}

View File

@ -0,0 +1,7 @@
package-repositories:
- type: apt
ppa: ucdev/ubuntu/base-ppa
packages:
- adduser=3.118ubuntu2
- apparmor=2.13.3-7ubuntu5.3
- gcc-10-base:amd64=10.5.0-1ubuntu1~20.04

View File

@ -0,0 +1,2 @@
packages:
- test-package=1.0.0

View File

@ -0,0 +1,3 @@
name: test-snap
version: 1.0
type: app

View File

@ -0,0 +1,4 @@
name: test-snap
version: 1.0
primed-stage-packages:
- test-package=1.0.0

View File

@ -0,0 +1,17 @@
snapcraft-version: 8.2.1.post5+git07b4ee15
snapcraft-started-at: '2024-04-29T06:47:56.067540Z'
snapcraft-os-release-id: ubuntu
snapcraft-os-release-version-id: '24.04'
name: pc
version: 24-0.1
summary: PC gadget for generic devices
description: |
This gadget enables generic pc devices to work with Ubuntu Core
base: core24
grade: stable
confinement: strict
architectures:
- amd64
primed-stage-packages:
- grub-efi-amd64-signed=1.202+2.12-1ubuntu7
- shim-signed=1.56+15.7-0ubuntu1

View File

@ -0,0 +1,14 @@
package-repositories:
- type: apt
ppa: ucdev/ubuntu/base-ppa
packages:
- adduser=3.118ubuntu2
- apparmor=2.13.3-7ubuntu5.3
- apt=2.0.10
- bash=5.0-6ubuntu1.2
- coreutils=8.30-3ubuntu2
- dpkg=1.19.7ubuntu3.2
- gcc-10-base:amd64=10.5.0-1ubuntu1~20.04
- libc6:amd64=2.31-0ubuntu9.16
- systemd=245.4-4ubuntu3.23
- ubuntu-keyring=2020.02.11.4

View File

@ -0,0 +1,7 @@
name: test-snap
version: 1.0.0
summary: A test snap
description: This is a test snap for testing purposes
base: core20
type: app
architecture: amd64

18
syft/pkg/snap.go Normal file
View File

@ -0,0 +1,18 @@
package pkg
const (
SnapTypeBase = "base"
SnapTypeKernel = "kernel"
SnapTypeApp = "app"
SnapTypeGadget = "gadget"
SnapTypeSnapd = "snapd"
)
type SnapEntry struct {
SnapType string `json:"snapType" yaml:"snapType"` // base, kernel, system, gadget, snapd
Base string `json:"base" yaml:"base"` // base snap name (e.g., core20, core22)
SnapName string `json:"snapName" yaml:"snapName"` // name of the snap
SnapVersion string `json:"snapVersion" yaml:"snapVersion"` // version of the snap
Architecture string `json:"architecture" yaml:"architecture"` // architecture (amd64, arm64, etc.)
}