diff --git a/.gitignore b/.gitignore index ec05de3b..0bfc21a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store .idea coverage.out +**/*.tar diff --git a/sbom/internal/formats/syft301/README.md b/sbom/internal/formats/syft301/README.md new file mode 100644 index 00000000..24f3e151 --- /dev/null +++ b/sbom/internal/formats/syft301/README.md @@ -0,0 +1,9 @@ +# Source +The contents of this directory is largely based on anchore/syft's internal +`syftjson` package. The version copied is from +[v0.41.1](https://github.com/anchore/syft/blob/07d3c9af52f241613971ccadd18c8f8ab67abc4e) +of Syft that supports Syft JSON Schema 3.0.1. + +The implementations of `decoder` and `validator` have been omitted for +simplicity, since they are not required for buildpacks' SBOM generation. + diff --git a/sbom/internal/formats/syft301/encoder.go b/sbom/internal/formats/syft301/encoder.go new file mode 100644 index 00000000..eb862bd3 --- /dev/null +++ b/sbom/internal/formats/syft301/encoder.go @@ -0,0 +1,19 @@ +package syft301 + +import ( + "encoding/json" + "io" + + "github.com/anchore/syft/syft/sbom" +) + +func encoder(output io.Writer, s sbom.SBOM) error { + doc := ToFormatModel(s) + + enc := json.NewEncoder(output) + // prevent > and < from being escaped in the payload + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + + return enc.Encode(&doc) +} diff --git a/sbom/internal/formats/syft301/encoder_test.go b/sbom/internal/formats/syft301/encoder_test.go new file mode 100644 index 00000000..cd2cd589 --- /dev/null +++ b/sbom/internal/formats/syft301/encoder_test.go @@ -0,0 +1,206 @@ +package syft301 + +import ( + "flag" + "testing" + + "github.com/anchore/syft/syft/file" + + "github.com/anchore/syft/syft/artifact" + + "github.com/anchore/syft/syft/linux" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" + + // "github.com/anchore/syft/internal/formats/common/testutils" + "github.com/paketo-buildpacks/packit/v2/sbom/internal/formats/common/testutils" +) + +var updateJson = flag.Bool("update-json", false, "update the *.golden files for json encoders") + +func TestDirectoryEncoder(t *testing.T) { + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + testutils.DirectoryInput(t), + *updateJson, + ) +} + +func TestImageEncoder(t *testing.T) { + testImage := "image-simple" + testutils.AssertEncoderAgainstGoldenImageSnapshot(t, + Format(), + testutils.ImageInput(t, testImage, testutils.FromSnapshot()), + testImage, + *updateJson, + ) +} + +func TestEncodeFullJSONDocument(t *testing.T) { + catalog := pkg.NewCatalog() + + p1 := pkg.Package{ + Name: "package-1", + Version: "1.0.1", + Locations: []source.Location{ + { + Coordinates: source.Coordinates{ + RealPath: "/a/place/a", + }, + }, + }, + Type: pkg.PythonPkg, + FoundBy: "the-cataloger-1", + Language: pkg.Python, + MetadataType: pkg.PythonPackageMetadataType, + Licenses: []string{"MIT"}, + Metadata: pkg.PythonPackageMetadata{ + Name: "package-1", + Version: "1.0.1", + Files: []pkg.PythonFileRecord{}, + }, + PURL: "a-purl-1", + CPEs: []pkg.CPE{ + pkg.MustCPE("cpe:2.3:*:some:package:1:*:*:*:*:*:*:*"), + }, + } + + p2 := pkg.Package{ + Name: "package-2", + Version: "2.0.1", + Locations: []source.Location{ + { + Coordinates: source.Coordinates{ + RealPath: "/b/place/b", + }, + }, + }, + Type: pkg.DebPkg, + FoundBy: "the-cataloger-2", + MetadataType: pkg.DpkgMetadataType, + Metadata: pkg.DpkgMetadata{ + Package: "package-2", + Version: "2.0.1", + Files: []pkg.DpkgFileRecord{}, + }, + PURL: "a-purl-2", + CPEs: []pkg.CPE{ + pkg.MustCPE("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*"), + }, + } + + catalog.Add(p1) + catalog.Add(p2) + + s := sbom.SBOM{ + Artifacts: sbom.Artifacts{ + PackageCatalog: catalog, + FileMetadata: map[source.Coordinates]source.FileMetadata{ + source.NewLocation("/a/place").Coordinates: { + Mode: 0775, + Type: "directory", + UserID: 0, + GroupID: 0, + }, + source.NewLocation("/a/place/a").Coordinates: { + Mode: 0775, + Type: "regularFile", + UserID: 0, + GroupID: 0, + }, + source.NewLocation("/b").Coordinates: { + Mode: 0775, + Type: "symbolicLink", + LinkDestination: "/c", + UserID: 0, + GroupID: 0, + }, + source.NewLocation("/b/place/b").Coordinates: { + Mode: 0644, + Type: "regularFile", + UserID: 1, + GroupID: 2, + }, + }, + FileDigests: map[source.Coordinates][]file.Digest{ + source.NewLocation("/a/place/a").Coordinates: { + { + Algorithm: "sha256", + Value: "366a3f5653e34673b875891b021647440d0127c2ef041e3b1a22da2a7d4f3703", + }, + }, + source.NewLocation("/b/place/b").Coordinates: { + { + Algorithm: "sha256", + Value: "1b3722da2a7d90d033b87581a2a3f12021647445653e34666ef041e3b4f3707c", + }, + }, + }, + FileContents: map[source.Coordinates]string{ + source.NewLocation("/a/place/a").Coordinates: "the-contents", + }, + LinuxDistribution: &linux.Release{ + ID: "redhat", + Version: "7", + VersionID: "7", + IDLike: []string{ + "rhel", + }, + }, + }, + Relationships: []artifact.Relationship{ + { + From: p1, + To: p2, + Type: artifact.OwnershipByFileOverlapRelationship, + Data: map[string]string{ + "file": "path", + }, + }, + }, + Source: source.Metadata{ + Scheme: source.ImageScheme, + ImageMetadata: source.ImageMetadata{ + UserInput: "user-image-input", + ID: "sha256:c2b46b4eb06296933b7cf0722683964e9ecbd93265b9ef6ae9642e3952afbba0", + ManifestDigest: "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368", + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + Tags: []string{ + "stereoscope-fixture-image-simple:85066c51088bdd274f7a89e99e00490f666c49e72ffc955707cd6e18f0e22c5b", + }, + Size: 38, + Layers: []source.LayerMetadata{ + { + MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", + Digest: "sha256:3de16c5b8659a2e8d888b8ded8427be7a5686a3c8c4e4dd30de20f362827285b", + Size: 22, + }, + { + MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", + Digest: "sha256:366a3f5653e34673b875891b021647440d0127c2ef041e3b1a22da2a7d4f3703", + Size: 16, + }, + }, + RawManifest: []byte("eyJzY2hlbWFWZXJzaW9uIjoyLCJtZWRpYVR5cGUiOiJh..."), + RawConfig: []byte("eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsImNvbmZp..."), + RepoDigests: []string{}, + }, + }, + Descriptor: sbom.Descriptor{ + Name: "syft", + Version: "v0.42.0-bogus", + // the application configuration should be persisted here, however, we do not want to import + // the application configuration in this package (it's reserved only for ingestion by the cmd package) + Configuration: map[string]string{ + "config-key": "config-value", + }, + }, + } + + testutils.AssertEncoderAgainstGoldenSnapshot(t, + Format(), + s, + *updateJson, + ) +} diff --git a/sbom/internal/formats/syft301/format.go b/sbom/internal/formats/syft301/format.go new file mode 100644 index 00000000..7b7ce8bd --- /dev/null +++ b/sbom/internal/formats/syft301/format.go @@ -0,0 +1,29 @@ +package syft301 + +import ( + "io" + + "github.com/anchore/syft/syft/sbom" +) + +const ID sbom.FormatID = "syft-3.0.1-json" +const JSONSchemaVersion string = "3.0.1" + +// Decoder not implemented because it's not needed for buildpacks' SBOM generation +var dummyDecoder func(io.Reader) (*sbom.SBOM, error) = func(input io.Reader) (*sbom.SBOM, error) { + return nil, nil +} + +// Validator not implemented because it's not needed for buildpacks' SBOM generation +var dummyValidator func(io.Reader) error = func(input io.Reader) error { + return nil +} + +func Format() sbom.Format { + return sbom.NewFormat( + ID, + encoder, + dummyDecoder, + dummyValidator, + ) +} diff --git a/sbom/internal/formats/syft301/model/document.go b/sbom/internal/formats/syft301/model/document.go new file mode 100644 index 00000000..eb41ec79 --- /dev/null +++ b/sbom/internal/formats/syft301/model/document.go @@ -0,0 +1,25 @@ +package model + +// Document represents the syft cataloging findings as a JSON document +type Document struct { + Artifacts []Package `json:"artifacts"` // Artifacts is the list of packages discovered and placed into the catalog + ArtifactRelationships []Relationship `json:"artifactRelationships"` + Files []File `json:"files,omitempty"` // note: must have omitempty + Secrets []Secrets `json:"secrets,omitempty"` // note: must have omitempty + Source Source `json:"source"` // Source represents the original object that was cataloged + Distro LinuxRelease `json:"distro"` // Distro represents the Linux distribution that was detected from the source + Descriptor Descriptor `json:"descriptor"` // Descriptor is a block containing self-describing information about syft + Schema Schema `json:"schema"` // Schema is a block reserved for defining the version for the shape of this JSON document and where to find the schema document to validate the shape +} + +// Descriptor describes what created the document as well as surrounding metadata +type Descriptor struct { + Name string `json:"name"` + Version string `json:"version"` + Configuration interface{} `json:"configuration,omitempty"` +} + +type Schema struct { + Version string `json:"version"` + URL string `json:"url"` +} diff --git a/sbom/internal/formats/syft301/model/file.go b/sbom/internal/formats/syft301/model/file.go new file mode 100644 index 00000000..be2c88df --- /dev/null +++ b/sbom/internal/formats/syft301/model/file.go @@ -0,0 +1,25 @@ +package model + +import ( + "github.com/anchore/syft/syft/file" + + "github.com/anchore/syft/syft/source" +) + +type File struct { + ID string `json:"id"` + Location source.Coordinates `json:"location"` + Metadata *FileMetadataEntry `json:"metadata,omitempty"` + Contents string `json:"contents,omitempty"` + Digests []file.Digest `json:"digests,omitempty"` + Classifications []file.Classification `json:"classifications,omitempty"` +} + +type FileMetadataEntry struct { + Mode int `json:"mode"` + Type source.FileType `json:"type"` + LinkDestination string `json:"linkDestination,omitempty"` + UserID int `json:"userID"` + GroupID int `json:"groupID"` + MIMEType string `json:"mimeType"` +} diff --git a/sbom/internal/formats/syft301/model/linux_release.go b/sbom/internal/formats/syft301/model/linux_release.go new file mode 100644 index 00000000..d8349600 --- /dev/null +++ b/sbom/internal/formats/syft301/model/linux_release.go @@ -0,0 +1,38 @@ +package model + +import ( + "encoding/json" +) + +type IDLikes []string + +type LinuxRelease struct { + PrettyName string `json:"prettyName,omitempty"` + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + IDLike IDLikes `json:"idLike,omitempty"` + Version string `json:"version,omitempty"` + VersionID string `json:"versionID,omitempty"` + Variant string `json:"variant,omitempty"` + VariantID string `json:"variantID,omitempty"` + HomeURL string `json:"homeURL,omitempty"` + SupportURL string `json:"supportURL,omitempty"` + BugReportURL string `json:"bugReportURL,omitempty"` + PrivacyPolicyURL string `json:"privacyPolicyURL,omitempty"` + CPEName string `json:"cpeName,omitempty"` +} + +func (s *IDLikes) UnmarshalJSON(data []byte) error { + var str string + var strSlice []string + + // we support unmarshalling from a single value to support syft json schema v2 + if err := json.Unmarshal(data, &str); err == nil { + *s = []string{str} + } else if err := json.Unmarshal(data, &strSlice); err == nil { + *s = strSlice + } else { + return err + } + return nil +} diff --git a/sbom/internal/formats/syft301/model/linux_release_test.go b/sbom/internal/formats/syft301/model/linux_release_test.go new file mode 100644 index 00000000..66487f59 --- /dev/null +++ b/sbom/internal/formats/syft301/model/linux_release_test.go @@ -0,0 +1,47 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIDLikes_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + data interface{} + expected IDLikes + }{ + { + name: "single string", + data: "well hello there!", + expected: IDLikes{ + "well hello there!", + }, + }, + { + name: "multiple strings", + data: []string{ + "well hello there!", + "...hello there, john!", + }, + expected: IDLikes{ + "well hello there!", + "...hello there, john!", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(&tt.data) + require.NoError(t, err) + + var obj IDLikes + require.NoError(t, json.Unmarshal(data, &obj)) + + assert.Equal(t, tt.expected, obj) + }) + } +} diff --git a/sbom/internal/formats/syft301/model/package.go b/sbom/internal/formats/syft301/model/package.go new file mode 100644 index 00000000..8ae1f005 --- /dev/null +++ b/sbom/internal/formats/syft301/model/package.go @@ -0,0 +1,132 @@ +package model + +import ( + "encoding/json" + "fmt" + + "github.com/anchore/syft/syft/source" + + // "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/pkg" +) + +// Package represents a pkg.Package object specialized for JSON marshaling and unmarshalling. +type Package struct { + PackageBasicData + PackageCustomData +} + +// PackageBasicData contains non-ambiguous values (type-wise) from pkg.Package. +type PackageBasicData struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Type pkg.Type `json:"type"` + FoundBy string `json:"foundBy"` + Locations []source.Coordinates `json:"locations"` + Licenses []string `json:"licenses"` + Language pkg.Language `json:"language"` + CPEs []string `json:"cpes"` + PURL string `json:"purl"` +} + +// PackageCustomData contains ambiguous values (type-wise) from pkg.Package. +type PackageCustomData struct { + MetadataType pkg.MetadataType `json:"metadataType,omitempty"` + Metadata interface{} `json:"metadata,omitempty"` +} + +// packageMetadataUnpacker is all values needed from Package to disambiguate ambiguous fields during json unmarshaling. +type packageMetadataUnpacker struct { + MetadataType pkg.MetadataType `json:"metadataType"` + Metadata json.RawMessage `json:"metadata"` +} + +func (p *packageMetadataUnpacker) String() string { + return fmt.Sprintf("metadataType: %s, metadata: %s", p.MetadataType, string(p.Metadata)) +} + +// UnmarshalJSON is a custom unmarshaller for handling basic values and values with ambiguous types. +// nolint:funlen +func (p *Package) UnmarshalJSON(b []byte) error { + var basic PackageBasicData + if err := json.Unmarshal(b, &basic); err != nil { + return err + } + p.PackageBasicData = basic + + var unpacker packageMetadataUnpacker + if err := json.Unmarshal(b, &unpacker); err != nil { + // log.Warnf("failed to unmarshall into packageMetadataUnpacker: %v", err) + return err + } + + p.MetadataType = unpacker.MetadataType + + switch p.MetadataType { + case pkg.ApkMetadataType: + var payload pkg.ApkMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.RpmdbMetadataType: + var payload pkg.RpmdbMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.DpkgMetadataType: + var payload pkg.DpkgMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.JavaMetadataType: + var payload pkg.JavaMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.RustCargoPackageMetadataType: + var payload pkg.CargoPackageMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.GemMetadataType: + var payload pkg.GemMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.KbPackageMetadataType: + var payload pkg.KbPackageMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.PythonPackageMetadataType: + var payload pkg.PythonPackageMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.NpmPackageJSONMetadataType: + var payload pkg.NpmPackageJSONMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + case pkg.PhpComposerJSONMetadataType: + var payload pkg.PhpComposerJSONMetadata + if err := json.Unmarshal(unpacker.Metadata, &payload); err != nil { + return err + } + p.Metadata = payload + default: + // log.Warnf("unknown package metadata type=%q for packageID=%q", p.MetadataType, p.ID) + } + + return nil +} diff --git a/sbom/internal/formats/syft301/model/relationship.go b/sbom/internal/formats/syft301/model/relationship.go new file mode 100644 index 00000000..46f6da22 --- /dev/null +++ b/sbom/internal/formats/syft301/model/relationship.go @@ -0,0 +1,8 @@ +package model + +type Relationship struct { + Parent string `json:"parent"` + Child string `json:"child"` + Type string `json:"type"` + Metadata interface{} `json:"metadata,omitempty"` +} diff --git a/sbom/internal/formats/syft301/model/secrets.go b/sbom/internal/formats/syft301/model/secrets.go new file mode 100644 index 00000000..c5f46857 --- /dev/null +++ b/sbom/internal/formats/syft301/model/secrets.go @@ -0,0 +1,11 @@ +package model + +import ( + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/source" +) + +type Secrets struct { + Location source.Coordinates `json:"location"` + Secrets []file.SearchResult `json:"secrets"` +} diff --git a/sbom/internal/formats/syft301/model/source.go b/sbom/internal/formats/syft301/model/source.go new file mode 100644 index 00000000..16940522 --- /dev/null +++ b/sbom/internal/formats/syft301/model/source.go @@ -0,0 +1,52 @@ +package model + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/anchore/syft/syft/source" +) + +// Source object represents the thing that was cataloged +type Source struct { + Type string `json:"type"` + Target interface{} `json:"target"` +} + +// sourceUnpacker is used to unmarshal Source objects +type sourceUnpacker struct { + Type string `json:"type"` + Target json.RawMessage `json:"target"` +} + +// UnmarshalJSON populates a source object from JSON bytes. +func (s *Source) UnmarshalJSON(b []byte) error { + var unpacker sourceUnpacker + if err := json.Unmarshal(b, &unpacker); err != nil { + return err + } + + s.Type = unpacker.Type + + switch s.Type { + case "directory", "file": + if target, err := strconv.Unquote(string(unpacker.Target)); err == nil { + s.Target = target + } else { + s.Target = string(unpacker.Target[:]) + } + + case "image": + var payload source.ImageMetadata + if err := json.Unmarshal(unpacker.Target, &payload); err != nil { + return err + } + s.Target = payload + + default: + return fmt.Errorf("unsupported package metadata type: %+v", s.Type) + } + + return nil +} diff --git a/sbom/internal/formats/syft301/model/source_test.go b/sbom/internal/formats/syft301/model/source_test.go new file mode 100644 index 00000000..cedf06e9 --- /dev/null +++ b/sbom/internal/formats/syft301/model/source_test.go @@ -0,0 +1,136 @@ +package model + +import ( + "encoding/json" + "testing" + + "github.com/anchore/syft/syft/source" + + "github.com/google/go-cmp/cmp" + + "github.com/stretchr/testify/assert" +) + +func TestSource_UnmarshalJSON(t *testing.T) { + cases := []struct { + name string + input []byte + expectedSource *Source + errAssertion assert.ErrorAssertionFunc + }{ + { + name: "directory", + input: []byte(`{ + "type": "directory", + "target":"/var/lib/foo" + }`), + expectedSource: &Source{ + Type: "directory", + Target: "/var/lib/foo", + }, + errAssertion: assert.NoError, + }, + { + name: "image", + input: []byte(`{ + "type": "image", + "target": { + "userInput": "alpine:3.10", + "imageID": "sha256:e7b300aee9f9bf3433d32bc9305bfdd22183beb59d933b48d77ab56ba53a197a", + "manifestDigest": "sha256:e515aad2ed234a5072c4d2ef86a1cb77d5bfe4b11aa865d9214875734c4eeb3c", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": [], + "imageSize": 5576169, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:9fb3aa2f8b8023a4bebbf92aa567caf88e38e969ada9f0ac12643b2847391635", + "size": 5576169 + } + ], + "manifest": "ewogICAic2NoZW1hVmVyc2lvbiI6IDIsCiAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5kaXN0cmlidXRpb24ubWFuaWZlc3QudjIranNvbiIsCiAgICJjb25maWciOiB7CiAgICAgICJtZWRpYVR5cGUiOiAiYXBwbGljYXRpb24vdm5kLmRvY2tlci5jb250YWluZXIuaW1hZ2UudjEranNvbiIsCiAgICAgICJzaXplIjogMTQ3MiwKICAgICAgImRpZ2VzdCI6ICJzaGEyNTY6ZTdiMzAwYWVlOWY5YmYzNDMzZDMyYmM5MzA1YmZkZDIyMTgzYmViNTlkOTMzYjQ4ZDc3YWI1NmJhNTNhMTk3YSIKICAgfSwKICAgImxheWVycyI6IFsKICAgICAgewogICAgICAgICAibWVkaWFUeXBlIjogImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuaW1hZ2Uucm9vdGZzLmRpZmYudGFyLmd6aXAiLAogICAgICAgICAic2l6ZSI6IDI3OTgzMzgsCiAgICAgICAgICJkaWdlc3QiOiAic2hhMjU2OjM5NmMzMTgzNzExNmFjMjkwNDU4YWZjYjkyOGY2OGI2Y2MxYzdiZGQ2OTYzZmM3MmY1MmYzNjVhMmE4OWMxYjUiCiAgICAgIH0KICAgXQp9", + "config": "eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsImNvbmZpZyI6eyJIb3N0bmFtZSI6IiIsIkRvbWFpbm5hbWUiOiIiLCJVc2VyIjoiIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iXSwiQ21kIjpbIi9iaW4vc2giXSwiSW1hZ2UiOiJzaGEyNTY6ZWIyMDgwYzQ1NWU5NGMyMmFlMzViM2FlZjllMDc4YzQ5MmEwMDc5NTQxMmUwMjZlNGQ2YjQxZWY2NGJjN2RkOCIsIlZvbHVtZXMiOm51bGwsIldvcmtpbmdEaXIiOiIiLCJFbnRyeXBvaW50IjpudWxsLCJPbkJ1aWxkIjpudWxsLCJMYWJlbHMiOm51bGx9LCJjb250YWluZXIiOiJmZGI3ZTgwZTMzMzllOGQwNTk5MjgyZTYwNmM5MDdhYTU4ODFlZTRjNjY4YTY4MTM2MTE5ZTZkZmFjNmNlM2E0IiwiY29udGFpbmVyX2NvbmZpZyI6eyJIb3N0bmFtZSI6ImZkYjdlODBlMzMzOSIsIkRvbWFpbm5hbWUiOiIiLCJVc2VyIjoiIiwiQXR0YWNoU3RkaW4iOmZhbHNlLCJBdHRhY2hTdGRvdXQiOmZhbHNlLCJBdHRhY2hTdGRlcnIiOmZhbHNlLCJUdHkiOmZhbHNlLCJPcGVuU3RkaW4iOmZhbHNlLCJTdGRpbk9uY2UiOmZhbHNlLCJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iXSwiQ21kIjpbIi9iaW4vc2giLCItYyIsIiMobm9wKSAiLCJDTUQgW1wiL2Jpbi9zaFwiXSJdLCJJbWFnZSI6InNoYTI1NjplYjIwODBjNDU1ZTk0YzIyYWUzNWIzYWVmOWUwNzhjNDkyYTAwNzk1NDEyZTAyNmU0ZDZiNDFlZjY0YmM3ZGQ4IiwiVm9sdW1lcyI6bnVsbCwiV29ya2luZ0RpciI6IiIsIkVudHJ5cG9pbnQiOm51bGwsIk9uQnVpbGQiOm51bGwsIkxhYmVscyI6e319LCJjcmVhdGVkIjoiMjAyMS0wNC0xNFQxOToyMDowNS4zMzgzOTc3NjFaIiwiZG9ja2VyX3ZlcnNpb24iOiIxOS4wMy4xMiIsImhpc3RvcnkiOlt7ImNyZWF0ZWQiOiIyMDIxLTA0LTE0VDE5OjIwOjA0Ljk4NzIxOTEyNFoiLCJjcmVhdGVkX2J5IjoiL2Jpbi9zaCAtYyAjKG5vcCkgQUREIGZpbGU6YzUzNzdlYWE5MjZiZjQxMmRkOGQ0YTA4YjBhMWYyMzk5Y2ZkNzA4NzQzNTMzYjBhYTAzYjUzZDE0Y2I0YmI0ZSBpbiAvICJ9LHsiY3JlYXRlZCI6IjIwMjEtMDQtMTRUMTk6MjA6MDUuMzM4Mzk3NzYxWiIsImNyZWF0ZWRfYnkiOiIvYmluL3NoIC1jICMobm9wKSAgQ01EIFtcIi9iaW4vc2hcIl0iLCJlbXB0eV9sYXllciI6dHJ1ZX1dLCJvcyI6ImxpbnV4Iiwicm9vdGZzIjp7InR5cGUiOiJsYXllcnMiLCJkaWZmX2lkcyI6WyJzaGEyNTY6OWZiM2FhMmY4YjgwMjNhNGJlYmJmOTJhYTU2N2NhZjg4ZTM4ZTk2OWFkYTlmMGFjMTI2NDNiMjg0NzM5MTYzNSJdfX0=", + "repoDigests": [ + "index.docker.io/library/alpine@sha256:451eee8bedcb2f029756dc3e9d73bab0e7943c1ac55cff3a4861c52a0fdd3e98" + ] + } + }`), + expectedSource: &Source{ + Type: "image", + Target: source.ImageMetadata{ + UserInput: "alpine:3.10", + ID: "sha256:e7b300aee9f9bf3433d32bc9305bfdd22183beb59d933b48d77ab56ba53a197a", + ManifestDigest: "sha256:e515aad2ed234a5072c4d2ef86a1cb77d5bfe4b11aa865d9214875734c4eeb3c", + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + Tags: []string{}, + Size: 5576169, + Layers: []source.LayerMetadata{ + { + MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", + Digest: "sha256:9fb3aa2f8b8023a4bebbf92aa567caf88e38e969ada9f0ac12643b2847391635", + Size: 5576169, + }, + }, + RawManifest: []byte(`{ + "schemaVersion": 2, + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "config": { + "mediaType": "application/vnd.docker.container.image.v1+json", + "size": 1472, + "digest": "sha256:e7b300aee9f9bf3433d32bc9305bfdd22183beb59d933b48d77ab56ba53a197a" + }, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "size": 2798338, + "digest": "sha256:396c31837116ac290458afcb928f68b6cc1c7bdd6963fc72f52f365a2a89c1b5" + } + ] +}`), + RawConfig: []byte(`{"architecture":"amd64","config":{"Hostname":"","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh"],"Image":"sha256:eb2080c455e94c22ae35b3aef9e078c492a00795412e026e4d6b41ef64bc7dd8","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":null},"container":"fdb7e80e3339e8d0599282e606c907aa5881ee4c668a68136119e6dfac6ce3a4","container_config":{"Hostname":"fdb7e80e3339","Domainname":"","User":"","AttachStdin":false,"AttachStdout":false,"AttachStderr":false,"Tty":false,"OpenStdin":false,"StdinOnce":false,"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/bin/sh\"]"],"Image":"sha256:eb2080c455e94c22ae35b3aef9e078c492a00795412e026e4d6b41ef64bc7dd8","Volumes":null,"WorkingDir":"","Entrypoint":null,"OnBuild":null,"Labels":{}},"created":"2021-04-14T19:20:05.338397761Z","docker_version":"19.03.12","history":[{"created":"2021-04-14T19:20:04.987219124Z","created_by":"/bin/sh -c #(nop) ADD file:c5377eaa926bf412dd8d4a08b0a1f2399cfd708743533b0aa03b53d14cb4bb4e in / "},{"created":"2021-04-14T19:20:05.338397761Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/sh\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:9fb3aa2f8b8023a4bebbf92aa567caf88e38e969ada9f0ac12643b2847391635"]}}`), + RepoDigests: []string{ + "index.docker." + + "io/library/alpine@sha256:451eee8bedcb2f029756dc3e9d73bab0e7943c1ac55cff3a4861c52a0fdd3e98", + }, + }, + }, + errAssertion: assert.NoError, + }, + { + name: "file", + input: []byte(`{ + "type": "file", + "target":"/var/lib/foo/go.mod" + }`), + expectedSource: &Source{ + Type: "file", + Target: "/var/lib/foo/go.mod", + }, + errAssertion: assert.NoError, + }, + { + name: "unknown source type", + input: []byte(`{ + "type": "unknown-thing", + "target":"/var/lib/foo" + }`), + expectedSource: &Source{ + Type: "unknown-thing", + }, + errAssertion: assert.Error, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + source := new(Source) + + err := json.Unmarshal(testCase.input, source) + + testCase.errAssertion(t, err) + if diff := cmp.Diff(testCase.expectedSource, source); diff != "" { + t.Errorf("unexpected result from Source unmarshaling (-want +got)\n%s", diff) + } + }) + } +} diff --git a/sbom/internal/formats/syft301/test-fixtures/image-simple/Dockerfile b/sbom/internal/formats/syft301/test-fixtures/image-simple/Dockerfile new file mode 100644 index 00000000..79cfa759 --- /dev/null +++ b/sbom/internal/formats/syft301/test-fixtures/image-simple/Dockerfile @@ -0,0 +1,4 @@ +# Note: changes to this file will result in updating several test values. Consider making a new image fixture instead of editing this one. +FROM scratch +ADD file-1.txt /somefile-1.txt +ADD file-2.txt /somefile-2.txt diff --git a/sbom/internal/formats/syft301/test-fixtures/image-simple/file-1.txt b/sbom/internal/formats/syft301/test-fixtures/image-simple/file-1.txt new file mode 100644 index 00000000..985d3408 --- /dev/null +++ b/sbom/internal/formats/syft301/test-fixtures/image-simple/file-1.txt @@ -0,0 +1 @@ +this file has contents \ No newline at end of file diff --git a/sbom/internal/formats/syft301/test-fixtures/image-simple/file-2.txt b/sbom/internal/formats/syft301/test-fixtures/image-simple/file-2.txt new file mode 100644 index 00000000..396d08bb --- /dev/null +++ b/sbom/internal/formats/syft301/test-fixtures/image-simple/file-2.txt @@ -0,0 +1 @@ +file-2 contents! \ No newline at end of file diff --git a/sbom/internal/formats/syft301/test-fixtures/snapshot/TestDirectoryEncoder.golden b/sbom/internal/formats/syft301/test-fixtures/snapshot/TestDirectoryEncoder.golden new file mode 100644 index 00000000..72607920 --- /dev/null +++ b/sbom/internal/formats/syft301/test-fixtures/snapshot/TestDirectoryEncoder.golden @@ -0,0 +1,94 @@ +{ + "artifacts": [ + { + "id": "1d97af55efe9512f", + "name": "package-1", + "version": "1.0.1", + "type": "python", + "foundBy": "the-cataloger-1", + "locations": [ + { + "path": "/some/path/pkg1" + } + ], + "licenses": [ + "MIT" + ], + "language": "python", + "cpes": [ + "cpe:2.3:*:some:package:2:*:*:*:*:*:*:*" + ], + "purl": "a-purl-2", + "metadataType": "PythonPackageMetadata", + "metadata": { + "name": "package-1", + "version": "1.0.1", + "license": "", + "author": "", + "authorEmail": "", + "platform": "", + "files": [ + { + "path": "/some/path/pkg1/dependencies/foo" + } + ], + "sitePackagesRootPath": "" + } + }, + { + "id": "ad3d1c4abd84bf75", + "name": "package-2", + "version": "2.0.1", + "type": "deb", + "foundBy": "the-cataloger-2", + "locations": [ + { + "path": "/some/path/pkg1" + } + ], + "licenses": [], + "language": "", + "cpes": [ + "cpe:2.3:*:some:package:2:*:*:*:*:*:*:*" + ], + "purl": "a-purl-2", + "metadataType": "DpkgMetadata", + "metadata": { + "package": "package-2", + "source": "", + "version": "2.0.1", + "sourceVersion": "", + "architecture": "", + "maintainer": "", + "installedSize": 0, + "files": null + } + } + ], + "artifactRelationships": [], + "source": { + "type": "directory", + "target": "/some/path" + }, + "distro": { + "prettyName": "debian", + "name": "debian", + "id": "debian", + "idLike": [ + "like!" + ], + "version": "1.2.3", + "versionID": "1.2.3" + }, + "descriptor": { + "name": "syft", + "version": "v0.42.0-bogus", + "configuration": { + "config-key": "config-value" + } + }, + "schema": { + "version": "3.0.1", + "url": "https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-3.0.1.json" + } +} diff --git a/sbom/internal/formats/syft301/test-fixtures/snapshot/TestEncodeFullJSONDocument.golden b/sbom/internal/formats/syft301/test-fixtures/snapshot/TestEncodeFullJSONDocument.golden new file mode 100644 index 00000000..f9441d41 --- /dev/null +++ b/sbom/internal/formats/syft301/test-fixtures/snapshot/TestEncodeFullJSONDocument.golden @@ -0,0 +1,190 @@ +{ + "artifacts": [ + { + "id": "d9a7c58726ab4bef", + "name": "package-1", + "version": "1.0.1", + "type": "python", + "foundBy": "the-cataloger-1", + "locations": [ + { + "path": "/a/place/a" + } + ], + "licenses": [ + "MIT" + ], + "language": "python", + "cpes": [ + "cpe:2.3:*:some:package:1:*:*:*:*:*:*:*" + ], + "purl": "a-purl-1", + "metadataType": "PythonPackageMetadata", + "metadata": { + "name": "package-1", + "version": "1.0.1", + "license": "", + "author": "", + "authorEmail": "", + "platform": "", + "sitePackagesRootPath": "" + } + }, + { + "id": "ac462e450060da2c", + "name": "package-2", + "version": "2.0.1", + "type": "deb", + "foundBy": "the-cataloger-2", + "locations": [ + { + "path": "/b/place/b" + } + ], + "licenses": [], + "language": "", + "cpes": [ + "cpe:2.3:*:some:package:2:*:*:*:*:*:*:*" + ], + "purl": "a-purl-2", + "metadataType": "DpkgMetadata", + "metadata": { + "package": "package-2", + "source": "", + "version": "2.0.1", + "sourceVersion": "", + "architecture": "", + "maintainer": "", + "installedSize": 0, + "files": [] + } + } + ], + "artifactRelationships": [ + { + "parent": "", + "child": "", + "type": "ownership-by-file-overlap", + "metadata": { + "file": "path" + } + } + ], + "files": [ + { + "id": "913b4592e2c2ebdf", + "location": { + "path": "/a/place" + }, + "metadata": { + "mode": 775, + "type": "directory", + "userID": 0, + "groupID": 0, + "mimeType": "" + } + }, + { + "id": "e7c88bd18e11b0b", + "location": { + "path": "/a/place/a" + }, + "metadata": { + "mode": 775, + "type": "regularFile", + "userID": 0, + "groupID": 0, + "mimeType": "" + }, + "contents": "the-contents", + "digests": [ + { + "algorithm": "sha256", + "value": "366a3f5653e34673b875891b021647440d0127c2ef041e3b1a22da2a7d4f3703" + } + ] + }, + { + "id": "5c3dc6885f48b5a1", + "location": { + "path": "/b" + }, + "metadata": { + "mode": 775, + "type": "symbolicLink", + "linkDestination": "/c", + "userID": 0, + "groupID": 0, + "mimeType": "" + } + }, + { + "id": "799d2f12da0bcec4", + "location": { + "path": "/b/place/b" + }, + "metadata": { + "mode": 644, + "type": "regularFile", + "userID": 1, + "groupID": 2, + "mimeType": "" + }, + "digests": [ + { + "algorithm": "sha256", + "value": "1b3722da2a7d90d033b87581a2a3f12021647445653e34666ef041e3b4f3707c" + } + ] + } + ], + "source": { + "type": "image", + "target": { + "userInput": "user-image-input", + "imageID": "sha256:c2b46b4eb06296933b7cf0722683964e9ecbd93265b9ef6ae9642e3952afbba0", + "manifestDigest": "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": [ + "stereoscope-fixture-image-simple:85066c51088bdd274f7a89e99e00490f666c49e72ffc955707cd6e18f0e22c5b" + ], + "imageSize": 38, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:3de16c5b8659a2e8d888b8ded8427be7a5686a3c8c4e4dd30de20f362827285b", + "size": 22 + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:366a3f5653e34673b875891b021647440d0127c2ef041e3b1a22da2a7d4f3703", + "size": 16 + } + ], + "manifest": "ZXlKelkyaGxiV0ZXWlhKemFXOXVJam95TENKdFpXUnBZVlI1Y0dVaU9pSmguLi4=", + "config": "ZXlKaGNtTm9hWFJsWTNSMWNtVWlPaUpoYldRMk5DSXNJbU52Ym1acC4uLg==", + "repoDigests": [], + "architecture": "", + "os": "" + } + }, + "distro": { + "id": "redhat", + "idLike": [ + "rhel" + ], + "version": "7", + "versionID": "7" + }, + "descriptor": { + "name": "syft", + "version": "v0.42.0-bogus", + "configuration": { + "config-key": "config-value" + } + }, + "schema": { + "version": "3.0.1", + "url": "https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-3.0.1.json" + } +} diff --git a/sbom/internal/formats/syft301/test-fixtures/snapshot/TestImageEncoder.golden b/sbom/internal/formats/syft301/test-fixtures/snapshot/TestImageEncoder.golden new file mode 100644 index 00000000..775d2fb0 --- /dev/null +++ b/sbom/internal/formats/syft301/test-fixtures/snapshot/TestImageEncoder.golden @@ -0,0 +1,117 @@ +{ + "artifacts": [ + { + "id": "d9527e708c11f8b9", + "name": "package-1", + "version": "1.0.1", + "type": "python", + "foundBy": "the-cataloger-1", + "locations": [ + { + "path": "/somefile-1.txt", + "layerID": "sha256:fb6beecb75b39f4bb813dbf177e501edd5ddb3e69bb45cedeb78c676ee1b7a59" + } + ], + "licenses": [ + "MIT" + ], + "language": "python", + "cpes": [ + "cpe:2.3:*:some:package:1:*:*:*:*:*:*:*" + ], + "purl": "a-purl-1", + "metadataType": "PythonPackageMetadata", + "metadata": { + "name": "package-1", + "version": "1.0.1", + "license": "", + "author": "", + "authorEmail": "", + "platform": "", + "sitePackagesRootPath": "" + } + }, + { + "id": "73f796c846875b9e", + "name": "package-2", + "version": "2.0.1", + "type": "deb", + "foundBy": "the-cataloger-2", + "locations": [ + { + "path": "/somefile-2.txt", + "layerID": "sha256:319b588ce64253a87b533c8ed01cf0025e0eac98e7b516e12532957e1244fdec" + } + ], + "licenses": [], + "language": "", + "cpes": [ + "cpe:2.3:*:some:package:2:*:*:*:*:*:*:*" + ], + "purl": "a-purl-2", + "metadataType": "DpkgMetadata", + "metadata": { + "package": "package-2", + "source": "", + "version": "2.0.1", + "sourceVersion": "", + "architecture": "", + "maintainer": "", + "installedSize": 0, + "files": null + } + } + ], + "artifactRelationships": [], + "source": { + "type": "image", + "target": { + "userInput": "user-image-input", + "imageID": "sha256:2480160b55bec40c44d3b145c7b2c1c47160db8575c3dcae086d76b9370ae7ca", + "manifestDigest": "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368", + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "tags": [ + "stereoscope-fixture-image-simple:85066c51088bdd274f7a89e99e00490f666c49e72ffc955707cd6e18f0e22c5b" + ], + "imageSize": 38, + "layers": [ + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:fb6beecb75b39f4bb813dbf177e501edd5ddb3e69bb45cedeb78c676ee1b7a59", + "size": 22 + }, + { + "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + "digest": "sha256:319b588ce64253a87b533c8ed01cf0025e0eac98e7b516e12532957e1244fdec", + "size": 16 + } + ], + "manifest": "eyJzY2hlbWFWZXJzaW9uIjoyLCJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmRpc3RyaWJ1dGlvbi5tYW5pZmVzdC52Mitqc29uIiwiY29uZmlnIjp7Im1lZGlhVHlwZSI6ImFwcGxpY2F0aW9uL3ZuZC5kb2NrZXIuY29udGFpbmVyLmltYWdlLnYxK2pzb24iLCJzaXplIjo2NjcsImRpZ2VzdCI6InNoYTI1NjoyNDgwMTYwYjU1YmVjNDBjNDRkM2IxNDVjN2IyYzFjNDcxNjBkYjg1NzVjM2RjYWUwODZkNzZiOTM3MGFlN2NhIn0sImxheWVycyI6W3sibWVkaWFUeXBlIjoiYXBwbGljYXRpb24vdm5kLmRvY2tlci5pbWFnZS5yb290ZnMuZGlmZi50YXIuZ3ppcCIsInNpemUiOjIwNDgsImRpZ2VzdCI6InNoYTI1NjpmYjZiZWVjYjc1YjM5ZjRiYjgxM2RiZjE3N2U1MDFlZGQ1ZGRiM2U2OWJiNDVjZWRlYjc4YzY3NmVlMWI3YTU5In0seyJtZWRpYVR5cGUiOiJhcHBsaWNhdGlvbi92bmQuZG9ja2VyLmltYWdlLnJvb3Rmcy5kaWZmLnRhci5nemlwIiwic2l6ZSI6MjA0OCwiZGlnZXN0Ijoic2hhMjU2OjMxOWI1ODhjZTY0MjUzYTg3YjUzM2M4ZWQwMWNmMDAyNWUwZWFjOThlN2I1MTZlMTI1MzI5NTdlMTI0NGZkZWMifV19", + "config": "eyJhcmNoaXRlY3R1cmUiOiJhbWQ2NCIsImNvbmZpZyI6eyJFbnYiOlsiUEFUSD0vdXNyL2xvY2FsL3NiaW46L3Vzci9sb2NhbC9iaW46L3Vzci9zYmluOi91c3IvYmluOi9zYmluOi9iaW4iXSwiV29ya2luZ0RpciI6Ii8iLCJPbkJ1aWxkIjpudWxsfSwiY3JlYXRlZCI6IjIwMjEtMTAtMDRUMTE6NDA6MDAuNjM4Mzk0NVoiLCJoaXN0b3J5IjpbeyJjcmVhdGVkIjoiMjAyMS0xMC0wNFQxMTo0MDowMC41OTA3MzE2WiIsImNyZWF0ZWRfYnkiOiJBREQgZmlsZS0xLnR4dCAvc29tZWZpbGUtMS50eHQgIyBidWlsZGtpdCIsImNvbW1lbnQiOiJidWlsZGtpdC5kb2NrZXJmaWxlLnYwIn0seyJjcmVhdGVkIjoiMjAyMS0xMC0wNFQxMTo0MDowMC42MzgzOTQ1WiIsImNyZWF0ZWRfYnkiOiJBREQgZmlsZS0yLnR4dCAvc29tZWZpbGUtMi50eHQgIyBidWlsZGtpdCIsImNvbW1lbnQiOiJidWlsZGtpdC5kb2NrZXJmaWxlLnYwIn1dLCJvcyI6ImxpbnV4Iiwicm9vdGZzIjp7InR5cGUiOiJsYXllcnMiLCJkaWZmX2lkcyI6WyJzaGEyNTY6ZmI2YmVlY2I3NWIzOWY0YmI4MTNkYmYxNzdlNTAxZWRkNWRkYjNlNjliYjQ1Y2VkZWI3OGM2NzZlZTFiN2E1OSIsInNoYTI1NjozMTliNTg4Y2U2NDI1M2E4N2I1MzNjOGVkMDFjZjAwMjVlMGVhYzk4ZTdiNTE2ZTEyNTMyOTU3ZTEyNDRmZGVjIl19fQ==", + "repoDigests": [], + "architecture": "", + "os": "" + } + }, + "distro": { + "prettyName": "debian", + "name": "debian", + "id": "debian", + "idLike": [ + "like!" + ], + "version": "1.2.3", + "versionID": "1.2.3" + }, + "descriptor": { + "name": "syft", + "version": "v0.42.0-bogus", + "configuration": { + "config-key": "config-value" + } + }, + "schema": { + "version": "3.0.1", + "url": "https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-3.0.1.json" + } +} diff --git a/sbom/internal/formats/syft301/test-fixtures/snapshot/stereoscope-fixture-image-simple.golden b/sbom/internal/formats/syft301/test-fixtures/snapshot/stereoscope-fixture-image-simple.golden new file mode 100644 index 00000000..c1b1d2b7 Binary files /dev/null and b/sbom/internal/formats/syft301/test-fixtures/snapshot/stereoscope-fixture-image-simple.golden differ diff --git a/sbom/internal/formats/syft301/to_format_model.go b/sbom/internal/formats/syft301/to_format_model.go new file mode 100644 index 00000000..a8619cfd --- /dev/null +++ b/sbom/internal/formats/syft301/to_format_model.go @@ -0,0 +1,246 @@ +package syft301 + +import ( + "fmt" + "sort" + "strconv" + + "github.com/anchore/syft/syft/linux" + + "github.com/anchore/syft/syft/file" + + "github.com/anchore/syft/syft/artifact" + + "github.com/anchore/syft/syft/sbom" + + // "github.com/anchore/syft/internal" + // "github.com/anchore/syft/internal/formats/syftjson/model" + "github.com/paketo-buildpacks/packit/v2/sbom/internal/formats/syft301/model" + // "github.com/anchore/syft/internal/log" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/source" +) + +// ToFormatModel transforms the sbom import a format-specific model. +// note: this is needed for anchore import functionality +// TODO: unexport this when/if anchore import functionality is removed +func ToFormatModel(s sbom.SBOM) model.Document { + src, err := toSourceModel(s.Source) + if err != nil { //nolint:staticcheck + // log.Warnf("unable to create syft-json source object: %+v", err) + } + + return model.Document{ + Artifacts: toPackageModels(s.Artifacts.PackageCatalog), + ArtifactRelationships: toRelationshipModel(s.Relationships), + Files: toFile(s), + Secrets: toSecrets(s.Artifacts.Secrets), + Source: src, + Distro: toLinuxReleaser(s.Artifacts.LinuxDistribution), + Descriptor: toDescriptor(s.Descriptor), + Schema: model.Schema{ + Version: JSONSchemaVersion, + URL: fmt.Sprintf("https://raw.githubusercontent.com/anchore/syft/main/schema/json/schema-%s.json", JSONSchemaVersion), + }, + } +} + +func toLinuxReleaser(d *linux.Release) model.LinuxRelease { + if d == nil { + return model.LinuxRelease{} + } + return model.LinuxRelease{ + PrettyName: d.PrettyName, + Name: d.Name, + ID: d.ID, + IDLike: d.IDLike, + Version: d.Version, + VersionID: d.VersionID, + Variant: d.Variant, + VariantID: d.VariantID, + HomeURL: d.HomeURL, + SupportURL: d.SupportURL, + BugReportURL: d.BugReportURL, + PrivacyPolicyURL: d.PrivacyPolicyURL, + CPEName: d.CPEName, + } +} + +func toDescriptor(d sbom.Descriptor) model.Descriptor { + return model.Descriptor{ + Name: d.Name, + Version: d.Version, + Configuration: d.Configuration, + } +} + +func toSecrets(data map[source.Coordinates][]file.SearchResult) []model.Secrets { + results := make([]model.Secrets, 0) + for coordinates, secrets := range data { + results = append(results, model.Secrets{ + Location: coordinates, + Secrets: secrets, + }) + } + + // sort by real path then virtual path to ensure the result is stable across multiple runs + sort.SliceStable(results, func(i, j int) bool { + return results[i].Location.RealPath < results[j].Location.RealPath + }) + return results +} + +func toFile(s sbom.SBOM) []model.File { + results := make([]model.File, 0) + artifacts := s.Artifacts + + for _, coordinates := range sbom.AllCoordinates(s) { + var metadata *source.FileMetadata + if metadataForLocation, exists := artifacts.FileMetadata[coordinates]; exists { + metadata = &metadataForLocation + } + + var digests []file.Digest + if digestsForLocation, exists := artifacts.FileDigests[coordinates]; exists { + digests = digestsForLocation + } + + var classifications []file.Classification + if classificationsForLocation, exists := artifacts.FileClassifications[coordinates]; exists { + classifications = classificationsForLocation + } + + var contents string + if contentsForLocation, exists := artifacts.FileContents[coordinates]; exists { + contents = contentsForLocation + } + + results = append(results, model.File{ + ID: string(coordinates.ID()), + Location: coordinates, + Metadata: toFileMetadataEntry(coordinates, metadata), + Digests: digests, + Classifications: classifications, + Contents: contents, + }) + } + + // sort by real path then virtual path to ensure the result is stable across multiple runs + sort.SliceStable(results, func(i, j int) bool { + return results[i].Location.RealPath < results[j].Location.RealPath + }) + return results +} + +func toFileMetadataEntry(coordinates source.Coordinates, metadata *source.FileMetadata) *model.FileMetadataEntry { + if metadata == nil { + return nil + } + + mode, err := strconv.Atoi(fmt.Sprintf("%o", metadata.Mode)) + if err != nil { + // log.Warnf("invalid mode found in file catalog @ location=%+v mode=%q: %+v", coordinates, metadata.Mode, err) + mode = 0 + } + + return &model.FileMetadataEntry{ + Mode: mode, + Type: metadata.Type, + LinkDestination: metadata.LinkDestination, + UserID: metadata.UserID, + GroupID: metadata.GroupID, + MIMEType: metadata.MIMEType, + } +} + +func toPackageModels(catalog *pkg.Catalog) []model.Package { + artifacts := make([]model.Package, 0) + if catalog == nil { + return artifacts + } + for _, p := range catalog.Sorted() { + artifacts = append(artifacts, toPackageModel(p)) + } + return artifacts +} + +// toPackageModel crates a new Package from the given pkg.Package. +func toPackageModel(p pkg.Package) model.Package { + var cpes = make([]string, len(p.CPEs)) + for i, c := range p.CPEs { + cpes[i] = pkg.CPEString(c) + } + + var licenses = make([]string, 0) + if p.Licenses != nil { + licenses = p.Licenses + } + + var coordinates = make([]source.Coordinates, len(p.Locations)) + for i, l := range p.Locations { + coordinates[i] = l.Coordinates + } + + return model.Package{ + PackageBasicData: model.PackageBasicData{ + ID: string(p.ID()), + Name: p.Name, + Version: p.Version, + Type: p.Type, + FoundBy: p.FoundBy, + Locations: coordinates, + Licenses: licenses, + Language: p.Language, + CPEs: cpes, + PURL: p.PURL, + }, + PackageCustomData: model.PackageCustomData{ + MetadataType: p.MetadataType, + Metadata: p.Metadata, + }, + } +} + +func toRelationshipModel(relationships []artifact.Relationship) []model.Relationship { + result := make([]model.Relationship, len(relationships)) + for i, r := range relationships { + result[i] = model.Relationship{ + Parent: string(r.From.ID()), + Child: string(r.To.ID()), + Type: string(r.Type), + Metadata: r.Data, + } + } + return result +} + +// toSourceModel creates a new source object to be represented into JSON. +func toSourceModel(src source.Metadata) (model.Source, error) { + switch src.Scheme { + case source.ImageScheme: + metadata := src.ImageMetadata + // ensure that empty collections are not shown as null + if metadata.RepoDigests == nil { + metadata.RepoDigests = []string{} + } + if metadata.Tags == nil { + metadata.Tags = []string{} + } + return model.Source{ + Type: "image", + Target: metadata, + }, nil + case source.DirectoryScheme: + return model.Source{ + Type: "directory", + Target: src.Path, + }, nil + case source.FileScheme: + return model.Source{ + Type: "file", + Target: src.Path, + }, nil + default: + return model.Source{}, fmt.Errorf("unsupported source: %q", src.Scheme) + } +} diff --git a/sbom/internal/formats/syft301/to_format_model_test.go b/sbom/internal/formats/syft301/to_format_model_test.go new file mode 100644 index 00000000..3de9aa8d --- /dev/null +++ b/sbom/internal/formats/syft301/to_format_model_test.go @@ -0,0 +1,87 @@ +package syft301 + +import ( + "testing" + + "github.com/scylladb/go-set/strset" + + // "github.com/anchore/syft/internal/formats/syftjson/model" + "github.com/anchore/syft/syft/source" + "github.com/paketo-buildpacks/packit/v2/sbom/internal/formats/syft301/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_toSourceModel(t *testing.T) { + allSchemes := strset.New() + for _, s := range source.AllSchemes { + allSchemes.Add(string(s)) + } + testedSchemes := strset.New() + + tests := []struct { + name string + src source.Metadata + expected model.Source + }{ + { + name: "directory", + src: source.Metadata{ + Scheme: source.DirectoryScheme, + Path: "some/path", + }, + expected: model.Source{ + Type: "directory", + Target: "some/path", + }, + }, + { + name: "file", + src: source.Metadata{ + Scheme: source.FileScheme, + Path: "some/path", + }, + expected: model.Source{ + Type: "file", + Target: "some/path", + }, + }, + { + name: "image", + src: source.Metadata{ + Scheme: source.ImageScheme, + ImageMetadata: source.ImageMetadata{ + UserInput: "user-input", + ID: "id...", + ManifestDigest: "digest...", + MediaType: "type...", + }, + }, + expected: model.Source{ + Type: "image", + Target: source.ImageMetadata{ + 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) { + // track each scheme tested (passed or not) + testedSchemes.Add(string(test.src.Scheme)) + + // assert the model transformation is correct + actual, err := toSourceModel(test.src) + require.NoError(t, err) + assert.Equal(t, test.expected, actual) + }) + } + + // assert all possible schemes were under test + assert.ElementsMatch(t, allSchemes.List(), testedSchemes.List(), "not all source.Schemes are under test") +}