diff --git a/go.mod b/go.mod index c9fcd1cfe..1b5877bbb 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,13 @@ go 1.17 require ( github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220216180153-3d7835abdf40 github.com/chrismellard/docker-credential-acr-env v0.0.0-20220119192733-fe33c00cee21 + github.com/dominodatalab/os-release v0.0.0-20190522011736-bcdb4a3e3c2f github.com/google/go-containerregistry v0.8.1-0.20220223122423-dd8d514a9b24 github.com/hashicorp/go-multierror v1.1.1 + github.com/maxbrunsfeld/counterfeiter/v6 v6.4.1 github.com/spf13/cobra v1.3.0 github.com/stretchr/testify v1.7.0 + gitlab.alpinelinux.org/alpine/go v0.3.0 go.lsp.dev/uri v0.3.0 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b @@ -61,9 +64,12 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/vbatts/tar-split v0.11.2 // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/mod v0.5.1 // indirect golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect + golang.org/x/tools v0.1.9 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.27.1 // indirect gotest.tools/v3 v3.0.3 // indirect diff --git a/go.sum b/go.sum index f29e468fc..defe95735 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= @@ -384,6 +386,8 @@ github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dominodatalab/os-release v0.0.0-20190522011736-bcdb4a3e3c2f h1:oEt43goQgsL1DzoOyQ/UZHQw7t9TqwyJec9W0vh0wfE= +github.com/dominodatalab/os-release v0.0.0-20190522011736-bcdb4a3e3c2f/go.mod h1:RU3x9VqPvzbOGJ3wtP0pPBtUOp4yU/yzA/8qdxgi/6Q= github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -775,6 +779,7 @@ github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOq github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/maxbrunsfeld/counterfeiter/v6 v6.4.1 h1:hZD/8vBuw7x1WqRXD/WGjVjipbbo/HcDBgySYYbrUSk= github.com/maxbrunsfeld/counterfeiter/v6 v6.4.1/go.mod h1:DK1Cjkc0E49ShgRVs5jy5ASrM15svSnem3K/hiSGD8o= github.com/mbilski/exhaustivestruct v1.2.0/go.mod h1:OeTBVxQWoEmB2J2JCHmXWPJ0aksxSUOUy+nvtVEfzXc= github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517/go.mod h1:KQ7+USdGKfpPjXk4Ga+5XxQM4Lm4e3gAogrreFAYpOg= @@ -860,6 +865,7 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg= +github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c= github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= @@ -975,6 +981,7 @@ github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYI github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sanposhiho/wastedassign/v2 v2.0.6/go.mod h1:KyZ0MWTwxxBmfwn33zh3k1dmsbF2ud9pAAGfoLfjhtI= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= @@ -1116,6 +1123,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +gitlab.alpinelinux.org/alpine/go v0.3.0 h1:4wVjXZRAd4rApnvVEFZqReDTdOe8ZLEKa8/egMPqVJM= +gitlab.alpinelinux.org/alpine/go v0.3.0/go.mod h1:auOw3SnxDQBo1vzPh8q6gjvKsYgKxYet03lgSKtA3Q4= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= @@ -1212,6 +1221,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1432,6 +1442,7 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1543,6 +1554,7 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/tools.go b/internal/tools.go new file mode 100644 index 000000000..f96df810c --- /dev/null +++ b/internal/tools.go @@ -0,0 +1,24 @@ +//go:build tools +// +build tools + +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// This is used to import things required by build scripts, to force `go mod` to see them as dependencies + +package internal + +import ( + _ "github.com/maxbrunsfeld/counterfeiter/v6" +) diff --git a/pkg/build/build.go b/pkg/build/build.go index a8d7abf41..b9cb5814a 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -33,6 +33,8 @@ type Context struct { Tags []string SourceDateEpoch time.Time Assertions []Assertion + WantSBOM bool + SBOMPath string } func (bc *Context) Summarize() { @@ -41,6 +43,7 @@ func (bc *Context) Summarize() { log.Printf(" tarball path: %s", bc.TarballPath) log.Printf(" use proot: %t", bc.UseProot) log.Printf(" source date: %s", bc.SourceDateEpoch) + log.Printf(" SBOM output path: %s", bc.SBOMPath) bc.ImageConfiguration.Summarize() } @@ -187,6 +190,14 @@ func WithBuildDate(s string) Option { } bc.SourceDateEpoch = t + + return nil + } +} + +func WithSBOM(path string) Option { + return func(bc *Context) error { + bc.SBOMPath = path return nil } } diff --git a/pkg/build/image_builder.go b/pkg/build/image_builder.go index 8a0d5362a..7945a18ee 100644 --- a/pkg/build/image_builder.go +++ b/pkg/build/image_builder.go @@ -106,6 +106,13 @@ func (bc *Context) BuildImage() error { return fmt.Errorf("failed to write supervision tree: %w", err) } + // generate SBOM + if bc.SBOMPath != "" { + if err := bc.GenerateSBOM(); err != nil { + return fmt.Errorf("failed to generate SBOM: %w", err) + } + } + log.Printf("finished building filesystem in %s", bc.WorkDir) return nil } diff --git a/pkg/build/sbom.go b/pkg/build/sbom.go new file mode 100644 index 000000000..3b40e6a16 --- /dev/null +++ b/pkg/build/sbom.go @@ -0,0 +1,45 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "fmt" + "log" + + "chainguard.dev/apko/pkg/sbom" +) + +// GenerateSBOM runs the sbom generation +func (bc *Context) GenerateSBOM() error { + log.Printf("generating SBOM") + + // TODO(puerco): Split GenerateSBOM into context implementation + s := sbom.NewWithWorkDir(bc.WorkDir) + + // Generate the packages externally as we may + // move the package reader somewhere else + packages, err := s.ReadPackageIndex() + if err != nil { + return fmt.Errorf("getting installed packagesx from sbom: %w", err) + } + s.Options.OutputDir = bc.SBOMPath + s.Options.Packages = packages + + if _, err := s.Generate(); err != nil { + return fmt.Errorf("generating SBOMs: %w", err) + } + + return nil +} diff --git a/pkg/cli/build-minirootfs.go b/pkg/cli/build-minirootfs.go index 5db913ead..a46683fde 100644 --- a/pkg/cli/build-minirootfs.go +++ b/pkg/cli/build-minirootfs.go @@ -27,6 +27,7 @@ import ( func BuildMinirootFS() *cobra.Command { var useProot bool var buildDate string + var sbomPath string cmd := &cobra.Command{ Use: "build-minirootfs", @@ -40,12 +41,14 @@ func BuildMinirootFS() *cobra.Command { build.WithTarball(args[1]), build.WithProot(useProot), build.WithBuildDate(buildDate), + build.WithSBOM(sbomPath), ) }, } cmd.Flags().BoolVar(&useProot, "use-proot", false, "use proot to simulate privileged operations") cmd.Flags().StringVar(&buildDate, "build-date", "", "date used for the timestamps of the files inside the image") + cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate an SBOM") return cmd } diff --git a/pkg/cli/build.go b/pkg/cli/build.go index 2b2b9b677..5f3664697 100644 --- a/pkg/cli/build.go +++ b/pkg/cli/build.go @@ -28,6 +28,7 @@ import ( func Build() *cobra.Command { var useProot bool var buildDate string + var sbomPath string cmd := &cobra.Command{ Use: "build", @@ -46,12 +47,14 @@ command, e.g. build.WithProot(useProot), build.WithBuildDate(buildDate), build.WithAssertions(build.RequireGroupFile(true), build.RequirePasswdFile(true)), + build.WithSBOM(sbomPath), ) }, } cmd.Flags().BoolVar(&useProot, "use-proot", false, "use proot to simulate privileged operations") cmd.Flags().StringVar(&buildDate, "build-date", "", "date used for the timestamps of the files inside the image") + cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate an SBOM") return cmd } diff --git a/pkg/cli/publish.go b/pkg/cli/publish.go index 355bfa1d9..6194e1bcc 100644 --- a/pkg/cli/publish.go +++ b/pkg/cli/publish.go @@ -29,6 +29,7 @@ func Publish() *cobra.Command { var imageRefs string var useProot bool var buildDate string + var sbomPath string cmd := &cobra.Command{ Use: "publish", @@ -45,6 +46,7 @@ in a keychain.`, build.WithProot(useProot), build.WithTags(args[1:]...), build.WithBuildDate(buildDate), + build.WithSBOM(sbomPath), ); err != nil { return err } @@ -55,6 +57,7 @@ in a keychain.`, cmd.Flags().StringVar(&imageRefs, "image-refs", "", "path to file where a list of the published image references will be written") cmd.Flags().BoolVar(&useProot, "use-proot", false, "use proot to simulate privileged operations") cmd.Flags().StringVar(&buildDate, "build-date", "", "date used for the timestamps of the files inside the image") + cmd.Flags().StringVar(&sbomPath, "sbom-path", "", "generate an SBOM") return cmd } diff --git a/pkg/sbom/generator/cyclonedx/cyclonedx.go b/pkg/sbom/generator/cyclonedx/cyclonedx.go new file mode 100644 index 000000000..0ce52f646 --- /dev/null +++ b/pkg/sbom/generator/cyclonedx/cyclonedx.go @@ -0,0 +1,155 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cyclonedx + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "chainguard.dev/apko/pkg/sbom/options" + "chainguard.dev/apko/pkg/sbom/purl" +) + +type CycloneDX struct{} + +func New() CycloneDX { + return CycloneDX{} +} + +func (cdx *CycloneDX) Key() string { + return "cyclonedx" +} + +func (cdx *CycloneDX) Ext() string { + return "cdx" +} + +// Generate writes a cyclondx sbom in path +func (cdx *CycloneDX) Generate(opts *options.Options, path string) error { + pkgComponents := []Component{} + pkgDependencies := []Dependency{} + + for _, pkg := range opts.Packages { + // add the component + c := Component{ + BOMRef: purl.Package(opts.OS.ID, pkg), + Name: pkg.Name, + Version: pkg.Version, + Description: pkg.Description, + Licenses: []License{ + { + Expression: pkg.License, + }, + }, + PUrl: purl.Package(opts.OS.ID, pkg), + // TODO(kaniini): Talk with CycloneDX people about adding "package" type. + Type: "operating-system", + } + + pkgComponents = append(pkgComponents, c) + + // walk the dependency list + depRefs := []string{} + for _, dep := range pkg.Dependencies { + // TODO(kaniini): Properly handle virtual dependencies... + if strings.ContainsRune(dep, ':') { + continue + } + + i := strings.IndexAny(dep, " ~<>=/!") + if i > -1 { + dep = dep[:i] + } + if dep == "" { + continue + } + + depRefs = append(depRefs, fmt.Sprintf("pkg:apk/%s/%s", opts.OS.ID, dep)) + } + + d := Dependency{ + Ref: purl.Package(opts.OS.ID, pkg), + DependsOn: depRefs, + } + pkgDependencies = append(pkgDependencies, d) + } + + rootComponent := Component{ + BOMRef: fmt.Sprintf("pkg:apk/%s", opts.OS.ID), + Name: opts.OS.Name, + Version: opts.OS.Version, + Type: "operating-system", + Components: pkgComponents, + } + + bom := Document{ + BOMFormat: "CycloneDX", + SpecVersion: "1.4", + Version: 1, + Components: []Component{rootComponent}, + Dependencies: pkgDependencies, + } + + out, err := os.Create(path) + if err != nil { + return fmt.Errorf("opening SBOM path %s for writing: %w", path, err) + } + defer out.Close() + + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + + if err := enc.Encode(bom); err != nil { + return fmt.Errorf("encoding BOM: %w", err) + } + return nil +} + +// TODO(kaniini): Move most of this over to gitlab.alpinelinux.org/alpine/go. +type Document struct { + BOMFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + Version int `json:"version"` + Components []Component `json:"components,omitempty"` + Dependencies []Dependency `json:"dependencies,omitempty"` +} + +type Component struct { + BOMRef string `json:"bom-ref"` + Type string `json:"type"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + PUrl string `json:"purl"` + ExternalReferences []ExternalReference `json:"externalReferences,omitempty"` + Licenses []License `json:"licenses,omitempty"` + Components []Component `json:"components,omitempty"` +} + +type License struct { + Expression string `json:"expression"` +} + +type ExternalReference struct { + URL string `json:"url"` + Type string `json:"type"` +} + +type Dependency struct { + Ref string `json:"ref"` + DependsOn []string `json:"dependsOn"` +} diff --git a/pkg/sbom/generator/generator.go b/pkg/sbom/generator/generator.go new file mode 100644 index 000000000..df8a61279 --- /dev/null +++ b/pkg/sbom/generator/generator.go @@ -0,0 +1,39 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package generator + +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate + +import ( + "chainguard.dev/apko/pkg/sbom/generator/cyclonedx" + "chainguard.dev/apko/pkg/sbom/options" +) + +//counterfeiter:generate . Generator + +type Generator interface { + Key() string + Ext() string + Generate(*options.Options, string) error +} + +func Generators() map[string]Generator { + generators := map[string]Generator{} + + cdx := cyclonedx.New() + generators[cdx.Key()] = &cdx + + return generators +} diff --git a/pkg/sbom/generator/generatorfakes/fake_generator.go b/pkg/sbom/generator/generatorfakes/fake_generator.go new file mode 100644 index 000000000..5a0eba264 --- /dev/null +++ b/pkg/sbom/generator/generatorfakes/fake_generator.go @@ -0,0 +1,244 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package generatorfakes + +import ( + "sync" + + "chainguard.dev/apko/pkg/sbom/generator" + "chainguard.dev/apko/pkg/sbom/options" +) + +type FakeGenerator struct { + ExtStub func() string + extMutex sync.RWMutex + extArgsForCall []struct { + } + extReturns struct { + result1 string + } + extReturnsOnCall map[int]struct { + result1 string + } + GenerateStub func(*options.Options, string) error + generateMutex sync.RWMutex + generateArgsForCall []struct { + arg1 *options.Options + arg2 string + } + generateReturns struct { + result1 error + } + generateReturnsOnCall map[int]struct { + result1 error + } + KeyStub func() string + keyMutex sync.RWMutex + keyArgsForCall []struct { + } + keyReturns struct { + result1 string + } + keyReturnsOnCall map[int]struct { + result1 string + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeGenerator) Ext() string { + fake.extMutex.Lock() + ret, specificReturn := fake.extReturnsOnCall[len(fake.extArgsForCall)] + fake.extArgsForCall = append(fake.extArgsForCall, struct { + }{}) + stub := fake.ExtStub + fakeReturns := fake.extReturns + fake.recordInvocation("Ext", []interface{}{}) + fake.extMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeGenerator) ExtCallCount() int { + fake.extMutex.RLock() + defer fake.extMutex.RUnlock() + return len(fake.extArgsForCall) +} + +func (fake *FakeGenerator) ExtCalls(stub func() string) { + fake.extMutex.Lock() + defer fake.extMutex.Unlock() + fake.ExtStub = stub +} + +func (fake *FakeGenerator) ExtReturns(result1 string) { + fake.extMutex.Lock() + defer fake.extMutex.Unlock() + fake.ExtStub = nil + fake.extReturns = struct { + result1 string + }{result1} +} + +func (fake *FakeGenerator) ExtReturnsOnCall(i int, result1 string) { + fake.extMutex.Lock() + defer fake.extMutex.Unlock() + fake.ExtStub = nil + if fake.extReturnsOnCall == nil { + fake.extReturnsOnCall = make(map[int]struct { + result1 string + }) + } + fake.extReturnsOnCall[i] = struct { + result1 string + }{result1} +} + +func (fake *FakeGenerator) Generate(arg1 *options.Options, arg2 string) error { + fake.generateMutex.Lock() + ret, specificReturn := fake.generateReturnsOnCall[len(fake.generateArgsForCall)] + fake.generateArgsForCall = append(fake.generateArgsForCall, struct { + arg1 *options.Options + arg2 string + }{arg1, arg2}) + stub := fake.GenerateStub + fakeReturns := fake.generateReturns + fake.recordInvocation("Generate", []interface{}{arg1, arg2}) + fake.generateMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeGenerator) GenerateCallCount() int { + fake.generateMutex.RLock() + defer fake.generateMutex.RUnlock() + return len(fake.generateArgsForCall) +} + +func (fake *FakeGenerator) GenerateCalls(stub func(*options.Options, string) error) { + fake.generateMutex.Lock() + defer fake.generateMutex.Unlock() + fake.GenerateStub = stub +} + +func (fake *FakeGenerator) GenerateArgsForCall(i int) (*options.Options, string) { + fake.generateMutex.RLock() + defer fake.generateMutex.RUnlock() + argsForCall := fake.generateArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeGenerator) GenerateReturns(result1 error) { + fake.generateMutex.Lock() + defer fake.generateMutex.Unlock() + fake.GenerateStub = nil + fake.generateReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeGenerator) GenerateReturnsOnCall(i int, result1 error) { + fake.generateMutex.Lock() + defer fake.generateMutex.Unlock() + fake.GenerateStub = nil + if fake.generateReturnsOnCall == nil { + fake.generateReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.generateReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeGenerator) Key() string { + fake.keyMutex.Lock() + ret, specificReturn := fake.keyReturnsOnCall[len(fake.keyArgsForCall)] + fake.keyArgsForCall = append(fake.keyArgsForCall, struct { + }{}) + stub := fake.KeyStub + fakeReturns := fake.keyReturns + fake.recordInvocation("Key", []interface{}{}) + fake.keyMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeGenerator) KeyCallCount() int { + fake.keyMutex.RLock() + defer fake.keyMutex.RUnlock() + return len(fake.keyArgsForCall) +} + +func (fake *FakeGenerator) KeyCalls(stub func() string) { + fake.keyMutex.Lock() + defer fake.keyMutex.Unlock() + fake.KeyStub = stub +} + +func (fake *FakeGenerator) KeyReturns(result1 string) { + fake.keyMutex.Lock() + defer fake.keyMutex.Unlock() + fake.KeyStub = nil + fake.keyReturns = struct { + result1 string + }{result1} +} + +func (fake *FakeGenerator) KeyReturnsOnCall(i int, result1 string) { + fake.keyMutex.Lock() + defer fake.keyMutex.Unlock() + fake.KeyStub = nil + if fake.keyReturnsOnCall == nil { + fake.keyReturnsOnCall = make(map[int]struct { + result1 string + }) + } + fake.keyReturnsOnCall[i] = struct { + result1 string + }{result1} +} + +func (fake *FakeGenerator) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.extMutex.RLock() + defer fake.extMutex.RUnlock() + fake.generateMutex.RLock() + defer fake.generateMutex.RUnlock() + fake.keyMutex.RLock() + defer fake.keyMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeGenerator) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ generator.Generator = new(FakeGenerator) diff --git a/pkg/sbom/options/options.go b/pkg/sbom/options/options.go new file mode 100644 index 000000000..353556bbe --- /dev/null +++ b/pkg/sbom/options/options.go @@ -0,0 +1,40 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package options + +import "gitlab.alpinelinux.org/alpine/go/pkg/repository" + +type Options struct { + OS struct { + Name string + ID string + Version string + } + + // Working directory,inherited from buid context + WorkDir string + + // OutputDir is the directory where the sboms will be written + OutputDir string + + // FileName is the base name for the sboms, the proper extension will get appended + FileName string + + // Formats dictates which SBOM formats we will output + Formats []string + + // Packages is alist of packages which will be listed in the SBOM + Packages []*repository.Package +} diff --git a/pkg/sbom/purl/purl.go b/pkg/sbom/purl/purl.go new file mode 100644 index 000000000..8c1e8fa8a --- /dev/null +++ b/pkg/sbom/purl/purl.go @@ -0,0 +1,29 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package purl + +import ( + "fmt" + + "gitlab.alpinelinux.org/alpine/go/pkg/repository" +) + +func Package(ns string, pkg *repository.Package) string { + return fmt.Sprintf("pkg:apk/%s/%s", ns, pkg.Name) +} + +func Versioned(ns string, pkg *repository.Package) string { + return fmt.Sprintf("pkg:apk/%s/%s@%s", ns, pkg.Name, pkg.Version) +} diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go new file mode 100644 index 000000000..549086180 --- /dev/null +++ b/pkg/sbom/sbom.go @@ -0,0 +1,189 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sbom + +//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate + +import ( + "fmt" + "os" + "path/filepath" + + "chainguard.dev/apko/pkg/sbom/generator" + "chainguard.dev/apko/pkg/sbom/options" + osr "github.com/dominodatalab/os-release" + "gitlab.alpinelinux.org/alpine/go/pkg/repository" +) + +const ( + osReleasePath = "/etc/os-release" + packageIndexPath = "/lib/apk/db/installed" +) + +var DefaultOptions = options.Options{ + OS: struct { + Name string + ID string + Version string + }{ + ID: "alpine", + Name: "Alpine Linux", + Version: "Unknown", + }, + FileName: "sbom", + Formats: []string{"cyclonedx"}, +} + +type SBOM struct { + Generators map[string]generator.Generator + impl sbomImplementation + Options options.Options +} + +func New() *SBOM { + return &SBOM{ + Generators: generator.Generators(), + impl: &defaultSBOMImplementation{}, + Options: DefaultOptions, + } +} + +// NewWithWorkDir returns a new sbom object with a working dir preset +func NewWithWorkDir(path string) *SBOM { + s := New() + s.Options.WorkDir = path + return s +} + +func (s *SBOM) SetImplementation(impl sbomImplementation) { + s.impl = impl +} + +func (s *SBOM) ReadReleaseData() error { + if err := s.impl.ReadReleaseData( + &s.Options, filepath.Join(s.Options.WorkDir, osReleasePath), + ); err != nil { + return fmt.Errorf("reading release data: %w", err) + } + return nil +} + +// ReadPackageIndex parses the package index in the working directory +// and returns a slice of the installed packages +func (s *SBOM) ReadPackageIndex() ([]*repository.Package, error) { + pks, err := s.impl.ReadPackageIndex( + &s.Options, filepath.Join(s.Options.WorkDir, packageIndexPath), + ) + if err != nil { + return nil, fmt.Errorf("reading apk package index: %w", err) + } + return pks, nil +} + +// Generate creates the sboms according to the options set +func (s *SBOM) Generate() ([]string, error) { + if err := s.impl.CheckGenerators( + &s.Options, s.Generators, + ); err != nil { + return nil, err + } + files, err := s.impl.Generate(&s.Options, s.Generators) + if err != nil { + return nil, fmt.Errorf("generating sboms: %w", err) + } + return files, nil +} + +//counterfeiter:generate . sbomImplementation +type sbomImplementation interface { + ReadReleaseData(*options.Options, string) error + ReadPackageIndex(*options.Options, string) ([]*repository.Package, error) + Generate(*options.Options, map[string]generator.Generator) ([]string, error) + CheckGenerators(*options.Options, map[string]generator.Generator) error +} + +type defaultSBOMImplementation struct{} + +// readReleaseDataInternal reads the information from /etc/os-release +func (di *defaultSBOMImplementation) ReadReleaseData(opts *options.Options, path string) error { + osReleaseData, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading os-release: %w", err) + } + + info := osr.Parse(string(osReleaseData)) + fmt.Printf("%+v", info) + + opts.OS.Name = info.Name + opts.OS.ID = info.ID + opts.OS.Version = info.VersionID + return nil +} + +// readPackageIndex parses the apk database passed in the path +func (di *defaultSBOMImplementation) ReadPackageIndex( + opts *options.Options, path string, +) (packages []*repository.Package, err error) { + installedDB, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening APK installed db: %w", err) + } + defer installedDB.Close() + + // repository.ParsePackageIndex closes the file itself + packages, err = repository.ParsePackageIndex(installedDB) + if err != nil { + return nil, fmt.Errorf("parsing APK installed db: %w", err) + } + return packages, nil +} + +// generate creates the documents according to the specified options +func (di *defaultSBOMImplementation) Generate( + opts *options.Options, generators map[string]generator.Generator, +) ([]string, error) { + files := []string{} + for _, format := range opts.Formats { + path := filepath.Join( + opts.OutputDir, opts.FileName+"."+generators[format].Ext(), + ) + if err := generators[format].Generate(opts, path); err != nil { + return nil, fmt.Errorf("generating %s sbom: %w", format, err) + } + files = append(files, path) + } + return files, nil +} + +// checkGenerators verifies we have generators available for the +// formats specified in the options +func (di *defaultSBOMImplementation) CheckGenerators( + opts *options.Options, generators map[string]generator.Generator, +) error { + if len(generators) == 0 { + return fmt.Errorf("no generators defined") + } + if len(opts.Formats) == 0 { + return fmt.Errorf("no sbom format enabled in options") + } + for _, format := range opts.Formats { + if _, ok := generators[format]; !ok { + return fmt.Errorf( + "unable to generate sboms: no generator available for format %s", format, + ) + } + } + return nil +} diff --git a/pkg/sbom/sbom_test.go b/pkg/sbom/sbom_test.go new file mode 100644 index 000000000..5e4cdef2f --- /dev/null +++ b/pkg/sbom/sbom_test.go @@ -0,0 +1,144 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sbom_test + +import ( + "fmt" + "testing" + + "chainguard.dev/apko/pkg/sbom" + "chainguard.dev/apko/pkg/sbom/sbomfakes" + "github.com/stretchr/testify/require" + "gitlab.alpinelinux.org/alpine/go/pkg/repository" +) + +var errFake = fmt.Errorf("synthetic error") + +func TestGenerate(t *testing.T) { + for _, tc := range []struct { + prepare func(*sbomfakes.FakeSbomImplementation) + assert func([]string, error) + }{ + { + // CheckGenerators errors + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.CheckGeneratorsReturns(errFake) + }, + assert: func(s []string, err error) { + require.Error(t, err) + }, + }, + { + // Generate fails + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.CheckGeneratorsReturns(nil) + fsi.GenerateReturns(nil, errFake) + }, + assert: func(s []string, err error) { + require.Error(t, err) + }, + }, + { + // Success + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.GenerateReturns([]string{"/path/to/sbom.cdx"}, nil) + }, + assert: func(s []string, err error) { + require.GreaterOrEqual(t, len(s), 1) + require.NoError(t, err) + }, + }, + } { + mock := &sbomfakes.FakeSbomImplementation{} + tc.prepare(mock) + + sut := sbom.SBOM{} + sut.SetImplementation(mock) + + obj, err := sut.Generate() + tc.assert(obj, err) + } +} + +func TestReadPackageIndes(t *testing.T) { + for _, tc := range []struct { + prepare func(*sbomfakes.FakeSbomImplementation) + assert func([]*repository.Package, error) + }{ + { + // ReadPackageIndex fails + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.ReadPackageIndexReturns(nil, errFake) + }, + assert: func(pkgs []*repository.Package, err error) { + require.Error(t, err) + }, + }, + { + // Success + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.ReadPackageIndexReturns([]*repository.Package{{}}, nil) + }, + assert: func(pkgs []*repository.Package, err error) { + require.GreaterOrEqual(t, len(pkgs), 1) + require.NoError(t, err) + }, + }, + } { + mock := &sbomfakes.FakeSbomImplementation{} + tc.prepare(mock) + + sut := sbom.SBOM{} + sut.SetImplementation(mock) + + obj, err := sut.ReadPackageIndex() + tc.assert(obj, err) + } +} + +func TestReadReleaseData(t *testing.T) { + for _, tc := range []struct { + prepare func(*sbomfakes.FakeSbomImplementation) + assert func(error) + }{ + { + // ReadReleaseData fails + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.ReadReleaseDataReturns(errFake) + }, + assert: func(err error) { + require.Error(t, err) + }, + }, + { + // Success + prepare: func(fsi *sbomfakes.FakeSbomImplementation) { + fsi.ReadReleaseDataReturns(nil) + }, + assert: func(err error) { + require.NoError(t, err) + }, + }, + } { + mock := &sbomfakes.FakeSbomImplementation{} + tc.prepare(mock) + + sut := sbom.SBOM{} + sut.SetImplementation(mock) + + err := sut.ReadReleaseData() + tc.assert(err) + } +} diff --git a/pkg/sbom/sbom_unit_test.go b/pkg/sbom/sbom_unit_test.go new file mode 100644 index 000000000..822b1f378 --- /dev/null +++ b/pkg/sbom/sbom_unit_test.go @@ -0,0 +1,201 @@ +// Copyright 2022 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sbom + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "chainguard.dev/apko/pkg/sbom/generator" + "chainguard.dev/apko/pkg/sbom/generator/generatorfakes" + + "chainguard.dev/apko/pkg/sbom/options" + "github.com/stretchr/testify/require" +) + +var errFake = fmt.Errorf("synthetic error") + +func TestReadReleaseData(t *testing.T) { + osinfoData := `NAME="Alpine Linux" +ID=alpine +VERSION_ID=3.15.0 +PRETTY_NAME="Alpine Linux v3.15" +HOME_URL="https://alpinelinux.org/" +BUG_REPORT_URL="https://bugs.alpinelinux.org/" +` + tdir := t.TempDir() + require.NoError( + t, os.WriteFile( + filepath.Join(tdir, "os-release"), []byte(osinfoData), os.FileMode(0o644), + ), + ) + di := defaultSBOMImplementation{} + + // Non existent file, should err + require.Error(t, di.ReadReleaseData(&options.Options{}, filepath.Join(tdir, "non-existent"))) + opts := options.Options{} + require.NoError(t, di.ReadReleaseData(&opts, filepath.Join(tdir, "os-release"))) + require.Equal(t, "alpine", opts.OS.ID) + require.Equal(t, "Alpine Linux", opts.OS.Name) + require.Equal(t, "3.15.0", opts.OS.Version) +} + +func TestReadPackageIndex(t *testing.T) { + sampleDB := ` +C:Q1Deb0jNytkrjPW4N/eKLZ43BwOlw= +P:musl +V:1.2.2-r7 +A:x86_64 +S:383152 +I:622592 +T:the musl c library (libc) implementation +U:https://musl.libc.org/ +L:MIT +o:musl +m:Pkg Author +t:1632431095 +c:bf5bbfdbf780092f387b7abe401fbfceda90c84d +p:so:libc.musl-x86_64.so.1=1 +F:lib +R:ld-musl-x86_64.so.1 +a:0:0:755 +Z:Q12adwqQOjo9dFl+VJD2Ecd901vhE= +R:libc.musl-x86_64.so.1 +a:0:0:777 +Z:Q17yJ3JFNypA4mxhJJr0ou6CzsJVI= + +C:Q1UQjutTNeqKQgMlKQyyZFnumOg3c= +P:libretls +V:3.3.4-r2 +A:x86_64 +S:29183 +I:86016 +T:port of libtls from libressl to openssl +U:https://git.causal.agency/libretls/ +L:ISC AND (BSD-3-Clause OR MIT) +o:libretls +m:Pkg Author +t:1634364270 +c:670bf5a8cc5bc605eede8ca2fd55b50a5c9f8660 +D:ca-certificates-bundle so:libc.musl-x86_64.so.1 so:libcrypto.so.1.1 so:libssl.so.1.1 +p:so:libtls.so.2=2.0.3 +F:usr +F:usr/lib +R:libtls.so.2 +a:0:0:777 +Z:Q1nNEC9T/t6W+Ecm0DxqMUnRvcT6k= +R:libtls.so.2.0.3 +a:0:0:755 +Z:Q1/KAM0XSmA+YShex9ZKehdaf+mjw= + +` + tdir := t.TempDir() + require.NoError( + t, os.WriteFile( + filepath.Join(tdir, "installed"), []byte(sampleDB), os.FileMode(0o644), + ), + ) + + // Write an invalid DB + require.NoError( + t, os.WriteFile( + filepath.Join(tdir, "installed-corrupt"), + []byte("sldkjflskdjflsjdflkjsdlfkjsldfkj\nskdjfhksjdhfkjhsdkfjhksdjhf"), + os.FileMode(0o644), + ), + ) + + di := defaultSBOMImplementation{} + + // Non existent file must fail + opts := &options.Options{} + _, err := di.ReadPackageIndex(opts, filepath.Join(tdir, "non-existent")) + require.Error(t, err) + _, err = di.ReadPackageIndex(opts, filepath.Join(tdir, "installed-corrupt")) + require.Error(t, err) + pkg, err := di.ReadPackageIndex(opts, filepath.Join(tdir, "installed")) + require.NoError(t, err) + require.NotNil(t, pkg) + require.Len(t, pkg, 2) +} + +func TestCheckGenerators(t *testing.T) { + di := defaultSBOMImplementation{} + gen := generatorfakes.FakeGenerator{} + + // No generators set + require.Error(t, di.CheckGenerators( + &options.Options{Formats: []string{"cyclonedx"}}, + map[string]generator.Generator{}, + )) + // No generators enabled in the options + require.Error(t, di.CheckGenerators( + &options.Options{Formats: []string{}}, + map[string]generator.Generator{"fake": &gen}, + )) + // No generator for specified format + require.Error(t, di.CheckGenerators( + &options.Options{Formats: []string{"cyclonedx"}}, + map[string]generator.Generator{"fake": &gen}, + )) + // Success + require.NoError(t, di.CheckGenerators( + &options.Options{Formats: []string{"fake"}}, + map[string]generator.Generator{"fake": &gen}, + )) +} + +func TestGenerate(t *testing.T) { + di := defaultSBOMImplementation{} + outputDir := "/path/to/sbom" + formats := []string{"fake"} + + for _, tc := range []struct { + prepare func(*generatorfakes.FakeGenerator) + opts options.Options + assert func([]string, error) + }{ + { + // Success + prepare: func(fg *generatorfakes.FakeGenerator) { + fg.GenerateReturns(nil) + }, + opts: options.Options{OutputDir: outputDir, Formats: formats}, + assert: func(sboms []string, err error) { + require.NoError(t, err) + require.GreaterOrEqual(t, len(sboms), 1) + }, + }, + { + // Generate fails + prepare: func(fg *generatorfakes.FakeGenerator) { + fg.GenerateReturns(errFake) + }, + opts: options.Options{OutputDir: outputDir, Formats: formats}, + assert: func(s []string, err error) { + require.Error(t, err) + }, + }, + } { + mock := &generatorfakes.FakeGenerator{} + tc.prepare(mock) + res, err := di.Generate( + &tc.opts, map[string]generator.Generator{"fake": mock}, + ) + tc.assert(res, err) + } +} diff --git a/pkg/sbom/sbomfakes/fake_sbom_implementation.go b/pkg/sbom/sbomfakes/fake_sbom_implementation.go new file mode 100644 index 000000000..6b923315b --- /dev/null +++ b/pkg/sbom/sbomfakes/fake_sbom_implementation.go @@ -0,0 +1,351 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package sbomfakes + +import ( + "sync" + + "chainguard.dev/apko/pkg/sbom/generator" + "chainguard.dev/apko/pkg/sbom/options" + "gitlab.alpinelinux.org/alpine/go/pkg/repository" +) + +type FakeSbomImplementation struct { + CheckGeneratorsStub func(*options.Options, map[string]generator.Generator) error + checkGeneratorsMutex sync.RWMutex + checkGeneratorsArgsForCall []struct { + arg1 *options.Options + arg2 map[string]generator.Generator + } + checkGeneratorsReturns struct { + result1 error + } + checkGeneratorsReturnsOnCall map[int]struct { + result1 error + } + GenerateStub func(*options.Options, map[string]generator.Generator) ([]string, error) + generateMutex sync.RWMutex + generateArgsForCall []struct { + arg1 *options.Options + arg2 map[string]generator.Generator + } + generateReturns struct { + result1 []string + result2 error + } + generateReturnsOnCall map[int]struct { + result1 []string + result2 error + } + ReadPackageIndexStub func(*options.Options, string) ([]*repository.Package, error) + readPackageIndexMutex sync.RWMutex + readPackageIndexArgsForCall []struct { + arg1 *options.Options + arg2 string + } + readPackageIndexReturns struct { + result1 []*repository.Package + result2 error + } + readPackageIndexReturnsOnCall map[int]struct { + result1 []*repository.Package + result2 error + } + ReadReleaseDataStub func(*options.Options, string) error + readReleaseDataMutex sync.RWMutex + readReleaseDataArgsForCall []struct { + arg1 *options.Options + arg2 string + } + readReleaseDataReturns struct { + result1 error + } + readReleaseDataReturnsOnCall map[int]struct { + result1 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeSbomImplementation) CheckGenerators(arg1 *options.Options, arg2 map[string]generator.Generator) error { + fake.checkGeneratorsMutex.Lock() + ret, specificReturn := fake.checkGeneratorsReturnsOnCall[len(fake.checkGeneratorsArgsForCall)] + fake.checkGeneratorsArgsForCall = append(fake.checkGeneratorsArgsForCall, struct { + arg1 *options.Options + arg2 map[string]generator.Generator + }{arg1, arg2}) + stub := fake.CheckGeneratorsStub + fakeReturns := fake.checkGeneratorsReturns + fake.recordInvocation("CheckGenerators", []interface{}{arg1, arg2}) + fake.checkGeneratorsMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSbomImplementation) CheckGeneratorsCallCount() int { + fake.checkGeneratorsMutex.RLock() + defer fake.checkGeneratorsMutex.RUnlock() + return len(fake.checkGeneratorsArgsForCall) +} + +func (fake *FakeSbomImplementation) CheckGeneratorsCalls(stub func(*options.Options, map[string]generator.Generator) error) { + fake.checkGeneratorsMutex.Lock() + defer fake.checkGeneratorsMutex.Unlock() + fake.CheckGeneratorsStub = stub +} + +func (fake *FakeSbomImplementation) CheckGeneratorsArgsForCall(i int) (*options.Options, map[string]generator.Generator) { + fake.checkGeneratorsMutex.RLock() + defer fake.checkGeneratorsMutex.RUnlock() + argsForCall := fake.checkGeneratorsArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSbomImplementation) CheckGeneratorsReturns(result1 error) { + fake.checkGeneratorsMutex.Lock() + defer fake.checkGeneratorsMutex.Unlock() + fake.CheckGeneratorsStub = nil + fake.checkGeneratorsReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSbomImplementation) CheckGeneratorsReturnsOnCall(i int, result1 error) { + fake.checkGeneratorsMutex.Lock() + defer fake.checkGeneratorsMutex.Unlock() + fake.CheckGeneratorsStub = nil + if fake.checkGeneratorsReturnsOnCall == nil { + fake.checkGeneratorsReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.checkGeneratorsReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeSbomImplementation) Generate(arg1 *options.Options, arg2 map[string]generator.Generator) ([]string, error) { + fake.generateMutex.Lock() + ret, specificReturn := fake.generateReturnsOnCall[len(fake.generateArgsForCall)] + fake.generateArgsForCall = append(fake.generateArgsForCall, struct { + arg1 *options.Options + arg2 map[string]generator.Generator + }{arg1, arg2}) + stub := fake.GenerateStub + fakeReturns := fake.generateReturns + fake.recordInvocation("Generate", []interface{}{arg1, arg2}) + fake.generateMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSbomImplementation) GenerateCallCount() int { + fake.generateMutex.RLock() + defer fake.generateMutex.RUnlock() + return len(fake.generateArgsForCall) +} + +func (fake *FakeSbomImplementation) GenerateCalls(stub func(*options.Options, map[string]generator.Generator) ([]string, error)) { + fake.generateMutex.Lock() + defer fake.generateMutex.Unlock() + fake.GenerateStub = stub +} + +func (fake *FakeSbomImplementation) GenerateArgsForCall(i int) (*options.Options, map[string]generator.Generator) { + fake.generateMutex.RLock() + defer fake.generateMutex.RUnlock() + argsForCall := fake.generateArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSbomImplementation) GenerateReturns(result1 []string, result2 error) { + fake.generateMutex.Lock() + defer fake.generateMutex.Unlock() + fake.GenerateStub = nil + fake.generateReturns = struct { + result1 []string + result2 error + }{result1, result2} +} + +func (fake *FakeSbomImplementation) GenerateReturnsOnCall(i int, result1 []string, result2 error) { + fake.generateMutex.Lock() + defer fake.generateMutex.Unlock() + fake.GenerateStub = nil + if fake.generateReturnsOnCall == nil { + fake.generateReturnsOnCall = make(map[int]struct { + result1 []string + result2 error + }) + } + fake.generateReturnsOnCall[i] = struct { + result1 []string + result2 error + }{result1, result2} +} + +func (fake *FakeSbomImplementation) ReadPackageIndex(arg1 *options.Options, arg2 string) ([]*repository.Package, error) { + fake.readPackageIndexMutex.Lock() + ret, specificReturn := fake.readPackageIndexReturnsOnCall[len(fake.readPackageIndexArgsForCall)] + fake.readPackageIndexArgsForCall = append(fake.readPackageIndexArgsForCall, struct { + arg1 *options.Options + arg2 string + }{arg1, arg2}) + stub := fake.ReadPackageIndexStub + fakeReturns := fake.readPackageIndexReturns + fake.recordInvocation("ReadPackageIndex", []interface{}{arg1, arg2}) + fake.readPackageIndexMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeSbomImplementation) ReadPackageIndexCallCount() int { + fake.readPackageIndexMutex.RLock() + defer fake.readPackageIndexMutex.RUnlock() + return len(fake.readPackageIndexArgsForCall) +} + +func (fake *FakeSbomImplementation) ReadPackageIndexCalls(stub func(*options.Options, string) ([]*repository.Package, error)) { + fake.readPackageIndexMutex.Lock() + defer fake.readPackageIndexMutex.Unlock() + fake.ReadPackageIndexStub = stub +} + +func (fake *FakeSbomImplementation) ReadPackageIndexArgsForCall(i int) (*options.Options, string) { + fake.readPackageIndexMutex.RLock() + defer fake.readPackageIndexMutex.RUnlock() + argsForCall := fake.readPackageIndexArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSbomImplementation) ReadPackageIndexReturns(result1 []*repository.Package, result2 error) { + fake.readPackageIndexMutex.Lock() + defer fake.readPackageIndexMutex.Unlock() + fake.ReadPackageIndexStub = nil + fake.readPackageIndexReturns = struct { + result1 []*repository.Package + result2 error + }{result1, result2} +} + +func (fake *FakeSbomImplementation) ReadPackageIndexReturnsOnCall(i int, result1 []*repository.Package, result2 error) { + fake.readPackageIndexMutex.Lock() + defer fake.readPackageIndexMutex.Unlock() + fake.ReadPackageIndexStub = nil + if fake.readPackageIndexReturnsOnCall == nil { + fake.readPackageIndexReturnsOnCall = make(map[int]struct { + result1 []*repository.Package + result2 error + }) + } + fake.readPackageIndexReturnsOnCall[i] = struct { + result1 []*repository.Package + result2 error + }{result1, result2} +} + +func (fake *FakeSbomImplementation) ReadReleaseData(arg1 *options.Options, arg2 string) error { + fake.readReleaseDataMutex.Lock() + ret, specificReturn := fake.readReleaseDataReturnsOnCall[len(fake.readReleaseDataArgsForCall)] + fake.readReleaseDataArgsForCall = append(fake.readReleaseDataArgsForCall, struct { + arg1 *options.Options + arg2 string + }{arg1, arg2}) + stub := fake.ReadReleaseDataStub + fakeReturns := fake.readReleaseDataReturns + fake.recordInvocation("ReadReleaseData", []interface{}{arg1, arg2}) + fake.readReleaseDataMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeSbomImplementation) ReadReleaseDataCallCount() int { + fake.readReleaseDataMutex.RLock() + defer fake.readReleaseDataMutex.RUnlock() + return len(fake.readReleaseDataArgsForCall) +} + +func (fake *FakeSbomImplementation) ReadReleaseDataCalls(stub func(*options.Options, string) error) { + fake.readReleaseDataMutex.Lock() + defer fake.readReleaseDataMutex.Unlock() + fake.ReadReleaseDataStub = stub +} + +func (fake *FakeSbomImplementation) ReadReleaseDataArgsForCall(i int) (*options.Options, string) { + fake.readReleaseDataMutex.RLock() + defer fake.readReleaseDataMutex.RUnlock() + argsForCall := fake.readReleaseDataArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeSbomImplementation) ReadReleaseDataReturns(result1 error) { + fake.readReleaseDataMutex.Lock() + defer fake.readReleaseDataMutex.Unlock() + fake.ReadReleaseDataStub = nil + fake.readReleaseDataReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeSbomImplementation) ReadReleaseDataReturnsOnCall(i int, result1 error) { + fake.readReleaseDataMutex.Lock() + defer fake.readReleaseDataMutex.Unlock() + fake.ReadReleaseDataStub = nil + if fake.readReleaseDataReturnsOnCall == nil { + fake.readReleaseDataReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.readReleaseDataReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeSbomImplementation) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.checkGeneratorsMutex.RLock() + defer fake.checkGeneratorsMutex.RUnlock() + fake.generateMutex.RLock() + defer fake.generateMutex.RUnlock() + fake.readPackageIndexMutex.RLock() + defer fake.readPackageIndexMutex.RUnlock() + fake.readReleaseDataMutex.RLock() + defer fake.readReleaseDataMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeSbomImplementation) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +}