diff --git a/.releaserc.yml b/.releaserc.yml index a4b5af1..637660f 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -10,4 +10,4 @@ branches: - name: alpha prerelease: true channel: alpha - - name: +([0-9])?(.{+([0-9]),x}).x \ No newline at end of file + - name: +([0-9])?(.{+([0-9]),x}).x} \ No newline at end of file diff --git a/go.mod b/go.mod index 5854263..13bd16c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ekristen/distillery go 1.22.4 require ( + github.com/ProtonMail/gopenpgp/v2 v2.7.5 github.com/apex/log v1.9.0 github.com/dsnet/compress v0.0.1 github.com/gabriel-vasile/mimetype v1.4.6 @@ -10,15 +11,20 @@ require ( github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/h2non/filetype v1.1.3 github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 + github.com/pelletier/go-toml/v2 v2.2.3 github.com/rancher/wrangler v1.1.2 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.9.0 github.com/ulikunitz/xz v0.5.12 github.com/urfave/cli/v2 v2.27.5 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect + github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect + github.com/cloudflare/circl v1.3.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.7.0 // indirect @@ -27,16 +33,15 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.2 // indirect github.com/mattn/go-isatty v0.0.8 // indirect - github.com/nicksnyder/go-i18n v1.10.3 // indirect - github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/crypto v0.28.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1681f53..722a32d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 h1:KLq8BE0KwCL+mmXnjLWEAOYO+2l2AE4YMmqG1ZpZHBs= +github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= +github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= +github.com/ProtonMail/gopenpgp/v2 v2.7.5 h1:STOY3vgES59gNgoOt2w0nyHBjKViB/qSg7NjbQWPJkA= +github.com/ProtonMail/gopenpgp/v2 v2.7.5/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g= github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0= github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA= github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo= @@ -5,6 +11,9 @@ github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys= github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -60,11 +69,8 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/nicksnyder/go-i18n v1.10.3 h1:0U60fnLBNrLBVt8vb8Q67yKNs+gykbQuLsIkiesJL+w= -github.com/nicksnyder/go-i18n v1.10.3/go.mod h1:hvLG5HTlZ4UfSuVLSRuX7JRUomIaoKQM19hm6f+no7o= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= @@ -112,24 +118,64 @@ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofm github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/asset/asset.go b/pkg/asset/asset.go index d8857b0..c0d8a71 100644 --- a/pkg/asset/asset.go +++ b/pkg/asset/asset.go @@ -16,6 +16,7 @@ import ( "slices" "strings" + "github.com/ProtonMail/gopenpgp/v2/crypto" "github.com/gabriel-vasile/mimetype" "github.com/h2non/filetype" "github.com/h2non/filetype/matchers" @@ -32,6 +33,8 @@ var ( apkType = filetype.AddType("apk", "application/vnd.android.package-archive") ascType = filetype.AddType("asc", "text/plain") pemType = filetype.AddType("pem", "application/x-pem-file") + certType = filetype.AddType("cert", "application/x-x509-ca-cert") + crtType = filetype.AddType("crt", "application/x-x509-ca-cert") sigType = filetype.AddType("sig", "text/plain") sbomJSONType = filetype.AddType("sbom.json", "application/json") bomJSONType = filetype.AddType("bom.json", "application/json") @@ -72,6 +75,10 @@ const ( Key SBOM Data + + ChecksumTypeNone = "none" + ChecksumTypeFile = "single" + ChecksumTypeMulti = "multi" ) // processorFunc is a function that processes a reader @@ -88,7 +95,14 @@ func New(name, displayName, osName, osArch, version string) *Asset { Files: make([]*File, 0), } - a.Classify() + a.Type = a.Classify(name) + + if a.Type == Key || a.Type == Signature || a.Type == Checksum { + parentName := strings.ReplaceAll(name, filepath.Ext(name), "") + parentName = strings.TrimSuffix(parentName, "-keyless") + + a.ParentType = a.Classify(parentName) + } return a } @@ -100,9 +114,12 @@ type File struct { } type Asset struct { - Name string - DisplayName string - Type Type + Name string + DisplayName string + Type Type + ParentType Type + ChecksumType string + MatchedAsset IAsset OS string Arch string @@ -131,6 +148,36 @@ func (a *Asset) GetDisplayName() string { func (a *Asset) GetType() Type { return a.Type } +func (a *Asset) GetParentType() Type { + return a.ParentType +} +func (a *Asset) GetChecksumType() string { + name := strings.ToLower(a.Name) + if strings.HasSuffix(name, ".sha512") || + strings.HasSuffix(name, ".sha256") || + strings.HasSuffix(name, ".md5") || + strings.HasSuffix(name, ".sha1") { + return ChecksumTypeFile + } + if strings.Contains(name, "checksums") || + strings.Contains(name, "checksum") { + return ChecksumTypeMulti + } + if strings.Contains(name, "sha") && + strings.Contains(name, "sums") { + return ChecksumTypeMulti + } else if strings.Contains(name, "sums") { + return ChecksumTypeMulti + } + return ChecksumTypeNone +} + +func (a *Asset) GetMatchedAsset() IAsset { + return a.MatchedAsset +} +func (a *Asset) SetMatchedAsset(asset IAsset) { + a.MatchedAsset = asset +} func (a *Asset) GetAsset() *Asset { return a @@ -152,55 +199,61 @@ func (a *Asset) GetFilePath() string { } // Classify determines the type of asset based on the file extension -func (a *Asset) Classify() { //nolint:gocyclo - if ext := strings.TrimPrefix(filepath.Ext(a.Name), "."); ext != "" { +func (a *Asset) Classify(name string) Type { //nolint:gocyclo + aType := Unknown + + if ext := strings.TrimPrefix(filepath.Ext(name), "."); ext != "" { switch filetype.GetType(ext) { case matchers.TypeDeb, matchers.TypeRpm, msiType, apkType: - a.Type = Installer + aType = Installer case matchers.TypeGz, matchers.TypeZip, matchers.TypeXz, matchers.TypeTar, matchers.TypeBz2, tarGzType: - a.Type = Archive + aType = Archive case matchers.TypeExe: - a.Type = Binary + aType = Binary case sigType, ascType: - a.Type = Signature - case pemType, pubType: - a.Type = Key + aType = Signature + case pemType, pubType, certType, crtType: + aType = Key case sbomJSONType, bomJSONType, sbomType, bomType: - a.Type = SBOM + aType = SBOM case jsonType: - a.Type = Data + aType = Data - if strings.Contains(a.Name, ".sbom") || strings.Contains(a.Name, ".bom") { - a.Type = SBOM + if strings.Contains(name, ".sbom") || strings.Contains(name, ".bom") { + aType = SBOM } default: - a.Type = Unknown + aType = Unknown } } - if a.Type == Unknown { - logrus.Tracef("classifying asset based on name: %s", a.Name) - name := strings.ToLower(a.Name) - if strings.Contains(name, ".sha256") || strings.Contains(name, ".md5") { - a.Type = Checksum + if aType == Unknown { + logrus.Tracef("classifying asset based on name: %s", name) + name = strings.ToLower(name) + if strings.HasSuffix(name, ".sha256") || strings.HasSuffix(name, ".md5") || strings.HasSuffix(name, ".sha1") { + aType = Checksum } if strings.Contains(name, "checksums") { - a.Type = Checksum + aType = Checksum } - if strings.Contains(a.Name, "SHA") && strings.Contains(a.Name, "SUMS") { - a.Type = Checksum - } else if strings.Contains(a.Name, "SUMS") { - a.Type = Checksum + if strings.Contains(name, "sha") && strings.Contains(name, "sums") { + aType = Checksum + } else if strings.Contains(name, "sums") { + aType = Checksum } + } - if strings.Contains(a.Name, "-pivkey-") { - a.Type = Key - } else if strings.Contains(a.Name, "pkcs") && strings.Contains(a.Name, "key") { - a.Type = Key + if aType == Unknown { + if strings.Contains(name, "-pivkey-") { + aType = Key + } else if strings.Contains(name, "pkcs") && strings.Contains(name, "key") { + aType = Key } } - logrus.Tracef("classified: %s (%d)", a.Name, a.Type) + logrus.Tracef("classified: %s - %s (type: %d)", name, aType, aType) + + return aType } func (a *Asset) copyFile(srcFile, dstFile string) error { @@ -573,6 +626,30 @@ func (a *Asset) processBz2(in io.Reader) (io.Reader, error) { return br, nil } +func (a *Asset) GetGPGKeyID() (uint64, error) { + if a.Type != Signature { + return 0, fmt.Errorf("asset is not a signature: %s", a.GetName()) + } + + signatureContent, err := os.ReadFile(a.GetFilePath()) + if err != nil { + return 0, fmt.Errorf("failed to read signature: %w", err) + } + + // Parse the armored signature + signature, err := crypto.NewPGPSignatureFromArmored(string(signatureContent)) + if err != nil { + return 0, fmt.Errorf("failed to parse signature: %w", err) + } + + ids, ok := signature.GetSignatureKeyIDs() + if !ok { + return 0, errors.New("signature does not contain a key ID") + } + + return ids[0], nil +} + func int64ToUint32(value int64) (uint32, error) { if value < 0 || value > math.MaxUint32 { return 0, errors.New("value out of range for uint32") diff --git a/pkg/asset/interface.go b/pkg/asset/interface.go index feeee2f..a1fead2 100644 --- a/pkg/asset/interface.go +++ b/pkg/asset/interface.go @@ -6,6 +6,7 @@ type IAsset interface { GetName() string GetDisplayName() string GetType() Type + GetParentType() Type GetAsset() *Asset GetFiles() []*File GetTempPath() string @@ -16,4 +17,8 @@ type IAsset interface { Cleanup() error ID() string Path() string + GetChecksumType() string + GetMatchedAsset() IAsset + SetMatchedAsset(IAsset) + GetGPGKeyID() (uint64, error) } diff --git a/pkg/cosign/bundle.go b/pkg/cosign/bundle.go new file mode 100644 index 0000000..dfda437 --- /dev/null +++ b/pkg/cosign/bundle.go @@ -0,0 +1,19 @@ +package cosign + +type Payload struct { + Body string `json:"body"` + IntegratedTime int64 `json:"integratedTime"` + LogIndex int64 `json:"logIndex"` + LogID string `json:"logID"` +} + +type Rekor struct { + SignedEntryTimestamp string `json:"SignedEntryTimestamp"` + Payload Payload `json:"Payload"` +} + +type Bundle struct { + Signature string `json:"base64Signature"` + Certificate string `json:"cert"` + RekorBundle Rekor `json:"rekorBundle"` +} diff --git a/pkg/cosign/cosign.go b/pkg/cosign/cosign.go index ebc2282..11c1ccb 100644 --- a/pkg/cosign/cosign.go +++ b/pkg/cosign/cosign.go @@ -7,8 +7,6 @@ import ( "encoding/base64" "encoding/pem" "errors" - "fmt" - "math/big" ) func ParsePublicKey(pemEncodedPubKey []byte) (*ecdsa.PublicKey, error) { @@ -46,33 +44,22 @@ func ParsePublicKey(pemEncodedPubKey []byte) (*ecdsa.PublicKey, error) { return ecdsaPub, nil } -// VerifySignature verifies the signature of the data using the provided ECDSA public key. -func VerifySignature(pubKey *ecdsa.PublicKey, data, signature []byte) (bool, error) { - hash := sha256.Sum256(data) - fmt.Printf("Data hash: %x\n", hash) - - r, s, err := decodeSignature(signature) - if err != nil { - return false, err - } - - fmt.Printf("r: %s\n", r.String()) - fmt.Printf("s: %s\n", s.String()) - - valid := ecdsa.Verify(pubKey, hash[:], r, s) - - return valid, nil +func HashData(data []byte) []byte { + hasher := sha256.New() + hasher.Write(data) + return hasher.Sum(nil) } -// decodeSignature decodes a base64 encoded signature into r and s values. -func decodeSignature(signature []byte) (*big.Int, *big.Int, error) { //nolint:gocritic +// VerifySignature verifies the signature of the data using the provided ECDSA public key. +func VerifySignature(pubKey *ecdsa.PublicKey, hash, signature []byte) (bool, error) { + // Decode the base64 encoded signature sig, err := base64.StdEncoding.DecodeString(string(signature)) if err != nil { - return nil, nil, err + return false, err } - r := new(big.Int).SetBytes(sig[:len(sig)/2]) - s := new(big.Int).SetBytes(sig[len(sig)/2:]) + // Verify the signature using VerifyASN1 + valid := ecdsa.VerifyASN1(pubKey, hash, sig) - return r, s, nil + return valid, nil } diff --git a/pkg/cosign/cosign_test.go b/pkg/cosign/cosign_test.go new file mode 100644 index 0000000..774d4b0 --- /dev/null +++ b/pkg/cosign/cosign_test.go @@ -0,0 +1,155 @@ +package cosign_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "os" + "testing" + + "github.com/ekristen/distillery/pkg/cosign" +) + +func TestParsePublicKey(t *testing.T) { + // Generate a test ECDSA key pair + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Failed to generate ECDSA key: %v", err) + } + pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + t.Fatalf("Failed to marshal public key: %v", err) + } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKeyBytes, + }) + + // Test parsing the public key + parsedPubKey, err := cosign.ParsePublicKey(pubKeyPEM) + if err != nil { + t.Fatalf("Failed to parse public key: %v", err) + } + if !parsedPubKey.Equal(&privKey.PublicKey) { + t.Fatalf("Parsed public key does not match original") + } +} + +func TestVerifySignature(t *testing.T) { + // Generate a test ECDSA key pair + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("Failed to generate ECDSA key: %v", err) + } + + // Create test data and sign it + data := []byte("test data") + hasher := sha256.New() + hasher.Write(data) + hash := hasher.Sum(nil) + + sig, err := ecdsa.SignASN1(rand.Reader, privKey, hash) + if err != nil { + t.Fatalf("Failed to sign data: %v", err) + } + + // Encode the signature in base64 + signatureBase64 := base64.StdEncoding.EncodeToString(sig) + fmt.Println("Signature:", signatureBase64) + + // Test verifying the signature + valid, err := cosign.VerifySignature(&privKey.PublicKey, hash, []byte(signatureBase64)) + if err != nil { + t.Fatalf("Failed to verify signature: %v", err) + } + if !valid { + t.Fatalf("Signature verification failed") + } +} + +func TestVerifyChecksumSignature(t *testing.T) { + // Read the contents of checksums.txt.pem + publicKeyContentEncoded, err := os.ReadFile("testdata/checksums.txt.pem") + if err != nil { + t.Fatalf("Failed to read public key file: %v", err) + } + + // Decode the base64-encoded public key + publicKeyContent, err := base64.StdEncoding.DecodeString(string(publicKeyContentEncoded)) + if err != nil { + t.Fatalf("Failed to decode base64 public key: %v", err) + } + + // Read the contents of checksums.txt.sig + signatureContent, err := os.ReadFile("testdata/checksums.txt.sig") + if err != nil { + t.Fatalf("Failed to read signature file: %v", err) + } + + // Read the contents of checksums.txt + dataContent, err := os.ReadFile("testdata/checksums.txt") + if err != nil { + t.Fatalf("Failed to read data file: %v", err) + } + + // Decode the PEM-encoded public key + pubKey, err := cosign.ParsePublicKey(publicKeyContent) + if err != nil { + t.Fatalf("Failed to parse public key: %v", err) + } + + dataHash := cosign.HashData(dataContent) + + // Verify the signature + valid, err := cosign.VerifySignature(pubKey, dataHash, signatureContent) + if err != nil { + t.Fatalf("Failed to verify signature: %v", err) + } + if !valid { + t.Fatalf("Signature verification failed") + } +} + +func TestVerifyChecksumSignaturePublicKey(t *testing.T) { + // Read the contents of checksums.txt.pem + publicKeyContent, err := os.ReadFile("testdata/release.pub") + if err != nil { + t.Fatalf("Failed to read public key file: %v", err) + } + + // Read the contents of checksums.txt.sig + signatureContent, err := os.ReadFile("testdata/release.sig") + if err != nil { + t.Fatalf("Failed to read signature file: %v", err) + } + + // Decode the PEM-encoded public key + pubKey, err := cosign.ParsePublicKey(publicKeyContent) + if err != nil { + t.Fatalf("Failed to parse public key: %v", err) + } + + dataHashEncoded, err := os.ReadFile("testdata/release.sha256") + if err != nil { + t.Fatalf("Failed to read data file: %v", err) + } + + dataHash, err := base64.StdEncoding.DecodeString(string(dataHashEncoded)) + if err != nil { + t.Fatalf("Failed to decode base64 data hash: %v", err) + } + + // Verify the signature + valid, err := cosign.VerifySignature(pubKey, dataHash, signatureContent) + if err != nil { + t.Fatalf("Failed to verify signature: %v", err) + } + if !valid { + t.Fatalf("Signature verification failed") + } +} diff --git a/pkg/cosign/testdata/checksums.txt b/pkg/cosign/testdata/checksums.txt new file mode 100644 index 0000000..153416a --- /dev/null +++ b/pkg/cosign/testdata/checksums.txt @@ -0,0 +1,16 @@ +2c73a240c7e877014352244f0f2f9691f5cc658ea120e3f17f7c25d4bf5625ed distillery-v1.0.0-beta.5-darwin-amd64.tar.gz +499b852f065051ea5b05790cf51357f615d5c33ba6fb1955861332c1f922cf53 distillery-v1.0.0-beta.5-darwin-amd64.tar.gz.sbom.json +2c4f2db1abb14eb159a999e9d4b584756f3736f87b4ee8bdf17666ec5e38c25e distillery-v1.0.0-beta.5-darwin-arm64.tar.gz +3ce280ad2562775f922f46f12a9292a8018f8d1bfb36f50c2df8a7bd82f135a7 distillery-v1.0.0-beta.5-darwin-arm64.tar.gz.sbom.json +9c60da14c6f8c19902ae049136ef57b28b6beaa9be540be432b8b48e28a79d67 distillery-v1.0.0-beta.5-freebsd-amd64.tar.gz +2aaea7a9c527f1cc3c5b2ff8eeb49b31640a700b8ef59f444903217f399e457f distillery-v1.0.0-beta.5-freebsd-amd64.tar.gz.sbom.json +e76feb402d57594bf5fbaa264377dedd0f702947bba8e69a81c0af47a624135d distillery-v1.0.0-beta.5-freebsd-arm64.tar.gz +2ec175aa5b7d9ac28d3a02a44f704e651563f2fd02e3f5bd1519a0a5db35c941 distillery-v1.0.0-beta.5-freebsd-arm64.tar.gz.sbom.json +6a25f595bcc64b079551ba26da1afe01bb66c3e822606b9200ee62bb2e62f44e distillery-v1.0.0-beta.5-linux-amd64.tar.gz +0398526313f08b3a07fe591918e5b3bb5003ee75b674fd3da2baf388cd59f999 distillery-v1.0.0-beta.5-linux-amd64.tar.gz.sbom.json +34b46e4ab7545328df658dc5c131a465ca3322499ff944675ad660625c853978 distillery-v1.0.0-beta.5-linux-arm64.tar.gz +6b833db31e577903008688266f7d0becfd2a79b1c10af32d065667c08d40b4ed distillery-v1.0.0-beta.5-linux-arm64.tar.gz.sbom.json +0abd6e72199274848dfd5f698fe64a64c10853723a8e0f70062e74b5b336fc63 distillery-v1.0.0-beta.5-windows-amd64.zip +51a11cbcddc44208172334e31582252c6125dcd8d5c854d3caf52d19c9670205 distillery-v1.0.0-beta.5-windows-amd64.zip.sbom.json +4b484f487873922cb60eda0d0e12727043048e1f694c34570bb55ce5ce375368 distillery-v1.0.0-beta.5-windows-arm64.zip +9cacc506317f27b3a7df9c1d88de635dcc861fada553a4c0fa7f8532ace834cd distillery-v1.0.0-beta.5-windows-arm64.zip.sbom.json diff --git a/pkg/cosign/testdata/checksums.txt.pem b/pkg/cosign/testdata/checksums.txt.pem new file mode 100644 index 0000000..25ea80b --- /dev/null +++ b/pkg/cosign/testdata/checksums.txt.pem @@ -0,0 +1 @@ +LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUc1ekNDQm0yZ0F3SUJBZ0lVVVdLenY1Vnc2UWVac1F3dmNadDN1K1J3UGI0d0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpReE1ESTJNVFUwTVRJeVdoY05NalF4TURJMk1UVTFNVEl5V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVlYnlseXNEQVFtdDJ3VXVkNmE2bjNIK1liUlpMRGttTUpzOVoKbjdYUnZ0YlFCZjdaeDMyTXY1WWg0eW9ldXlwbzNNc1djWE84TFlXa1FGS05SQ2gzV0tPQ0JZd3dnZ1dJTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVkTE9aCmRDZ21PSVo0ZFpWcGNYOGJBK3BhMnRJd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d2JRWURWUjBSQVFIL0JHTXdZWVpmYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDJWcmNtbHpkR1Z1TDJScApjM1JwYkd4bGNua3ZMbWRwZEdoMVlpOTNiM0pyWm14dmQzTXZaMjl5Wld4bFlYTmxjaTU1Yld4QWNtVm1jeTkwCllXZHpMM1l4TGpBdU1DMWlaWFJoTGpVd09RWUtLd1lCQkFHRHZ6QUJBUVFyYUhSMGNITTZMeTkwYjJ0bGJpNWgKWTNScGIyNXpMbWRwZEdoMVluVnpaWEpqYjI1MFpXNTBMbU52YlRBVkJnb3JCZ0VFQVlPL01BRUNCQWR5Wld4bApZWE5sTURZR0Npc0dBUVFCZzc4d0FRTUVLRFkyWTJNMk0yRm1NRFJsTWpabFpqazRNbU0wTkRNMk5HTmhaRFF4ClpXVXpOVEUzTVRNMU5ETXdHQVlLS3dZQkJBR0R2ekFCQkFRS1oyOXlaV3hsWVhObGNqQWhCZ29yQmdFRUFZTy8KTUFFRkJCTmxhM0pwYzNSbGJpOWthWE4wYVd4c1pYSjVNQ1VHQ2lzR0FRUUJnNzh3QVFZRUYzSmxabk12ZEdGbgpjeTkyTVM0d0xqQXRZbVYwWVM0MU1Ec0dDaXNHQVFRQmc3OHdBUWdFTFF3cmFIUjBjSE02THk5MGIydGxiaTVoClkzUnBiMjV6TG1kcGRHaDFZblZ6WlhKamIyNTBaVzUwTG1OdmJUQnZCZ29yQmdFRUFZTy9NQUVKQkdFTVgyaDAKZEhCek9pOHZaMmwwYUhWaUxtTnZiUzlsYTNKcGMzUmxiaTlrYVhOMGFXeHNaWEo1THk1bmFYUm9kV0l2ZDI5eQphMlpzYjNkekwyZHZjbVZzWldGelpYSXVlVzFzUUhKbFpuTXZkR0ZuY3k5Mk1TNHdMakF0WW1WMFlTNDFNRGdHCkNpc0dBUVFCZzc4d0FRb0VLZ3dvTmpaall6WXpZV1l3TkdVeU5tVm1PVGd5WXpRME16WTBZMkZrTkRGbFpUTTEKTVRjeE16VTBNekFkQmdvckJnRUVBWU8vTUFFTEJBOE1EV2RwZEdoMVlpMW9iM04wWldRd05nWUtLd1lCQkFHRAp2ekFCREFRb0RDWm9kSFJ3Y3pvdkwyZHBkR2gxWWk1amIyMHZaV3R5YVhOMFpXNHZaR2x6ZEdsc2JHVnllVEE0CkJnb3JCZ0VFQVlPL01BRU5CQ29NS0RZMlkyTTJNMkZtTURSbE1qWmxaams0TW1NME5ETTJOR05oWkRReFpXVXoKTlRFM01UTTFORE13SndZS0t3WUJCQUdEdnpBQkRnUVpEQmR5WldaekwzUmhaM012ZGpFdU1DNHdMV0psZEdFdQpOVEFaQmdvckJnRUVBWU8vTUFFUEJBc01DVGd5TURJeU5qZzNOekFyQmdvckJnRUVBWU8vTUFFUUJCME1HMmgwCmRIQnpPaTh2WjJsMGFIVmlMbU52YlM5bGEzSnBjM1JsYmpBVkJnb3JCZ0VFQVlPL01BRVJCQWNNQlRRNE16STUKTUc4R0Npc0dBUVFCZzc4d0FSSUVZUXhmYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDJWcmNtbHpkR1Z1TDJScApjM1JwYkd4bGNua3ZMbWRwZEdoMVlpOTNiM0pyWm14dmQzTXZaMjl5Wld4bFlYTmxjaTU1Yld4QWNtVm1jeTkwCllXZHpMM1l4TGpBdU1DMWlaWFJoTGpVd09BWUtLd1lCQkFHRHZ6QUJFd1FxRENnMk5tTmpOak5oWmpBMFpUSTIKWldZNU9ESmpORFF6TmpSallXUTBNV1ZsTXpVeE56RXpOVFF6TUJjR0Npc0dBUVFCZzc4d0FSUUVDUXdIY21WcwpaV0Z6WlRCYUJnb3JCZ0VFQVlPL01BRVZCRXdNU21oMGRIQnpPaTh2WjJsMGFIVmlMbU52YlM5bGEzSnBjM1JsCmJpOWthWE4wYVd4c1pYSjVMMkZqZEdsdmJuTXZjblZ1Y3k4eE1UVXpNekF4TkRNMU5TOWhkSFJsYlhCMGN5OHgKTUJZR0Npc0dBUVFCZzc4d0FSWUVDQXdHY0hWaWJHbGpNSUdLQmdvckJnRUVBZFo1QWdRQ0JId0VlZ0I0QUhZQQozVDB3YXNiSEVUSmpHUjRjbVdjM0FxSktYcmplUEszL2g0cHlnQzhwN280QUFBR1N5WDFDMHdBQUJBTUFSekJGCkFpRUFvTWFCVTVKQ0JWSEJ5cFVmZHNGbzRVb3RES0hHRnB5UXZBZHUvWTJHc2hFQ0lIZFBhQUFSbThUVzdGZWMKUk1ibExkS2h1K2VTWU9LNmhnR1J2c2pxVDJtbk1Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RRFByaU5QLzY2LwpNdWlkdnAwcnBxSmdhcFRwTGR0QXRLMVp1aldZQnhzTEFjZWN3dVZBNFhvczNiVVhlK0ZXeDZNQ01DWGVLZG5xCm9vSDNpVUdraVFQWDBDUjBTOWFYYnpUZnhJaXo2bnp1OHk5R3RpM0lYRCtXaGlRMkhzL2tQWjVkV3c9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== \ No newline at end of file diff --git a/pkg/cosign/testdata/checksums.txt.sig b/pkg/cosign/testdata/checksums.txt.sig new file mode 100644 index 0000000..00f8cfc --- /dev/null +++ b/pkg/cosign/testdata/checksums.txt.sig @@ -0,0 +1 @@ +MEQCIDyCHQ5HWS/xRM+rCSpSDmYc7P0V/jDf4fh/HP1X7nahAiBVRqNOIOS8vPqx3/oN060c4dB0ICyDEmQdLxck1xwd+Q== \ No newline at end of file diff --git a/pkg/cosign/testdata/release.pub b/pkg/cosign/testdata/release.pub new file mode 100644 index 0000000..9b898c8 --- /dev/null +++ b/pkg/cosign/testdata/release.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhyQCx0E9wQWSFI9ULGwy3BuRklnt +IqozONbbdbqz11hlRJy9c7SG+hdcFl9jE9uE/dwtuwU2MqU9T/cN0YkWww== +-----END PUBLIC KEY----- diff --git a/pkg/cosign/testdata/release.sha256 b/pkg/cosign/testdata/release.sha256 new file mode 100644 index 0000000..2c71908 --- /dev/null +++ b/pkg/cosign/testdata/release.sha256 @@ -0,0 +1 @@ +ZmAyyig9qStveVOWVoj9USAP3IkahsGeBcmLiY6gr04= \ No newline at end of file diff --git a/pkg/cosign/testdata/release.sig b/pkg/cosign/testdata/release.sig new file mode 100644 index 0000000..4eed484 --- /dev/null +++ b/pkg/cosign/testdata/release.sig @@ -0,0 +1 @@ +MEUCID0hl3VHhsWsmTt1MZjjCaxK41JuLHpAorWWGZtIZFh7AiEAk97CdygXFZFSVq46tZsFiXBoRzG4e21n8I63/w9fVUU= \ No newline at end of file diff --git a/pkg/provider/gpg_asset.go b/pkg/provider/gpg_asset.go new file mode 100644 index 0000000..b8f2175 --- /dev/null +++ b/pkg/provider/gpg_asset.go @@ -0,0 +1,116 @@ +package provider + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + + "github.com/sirupsen/logrus" + + "github.com/ekristen/distillery/pkg/asset" + "github.com/ekristen/distillery/pkg/common" +) + +type GPGAsset struct { + *asset.Asset + + KeyID uint64 + + Source ISource +} + +func (a *GPGAsset) ID() string { + return fmt.Sprintf("%s-%d", a.GetType(), a.KeyID) +} + +func (a *GPGAsset) Path() string { + return filepath.Join("gpg", strconv.FormatUint(a.KeyID, 10)) +} + +func (a *GPGAsset) Download(ctx context.Context) error { + cacheDir, err := os.UserCacheDir() + if err != nil { + return err + } + + a.KeyID, err = a.MatchedAsset.GetGPGKeyID() + if err != nil { + return err + } + + downloadsDir := filepath.Join(cacheDir, common.NAME, "downloads") + filename := strconv.FormatUint(a.KeyID, 10) + + assetFile := filepath.Join(downloadsDir, filename) + a.DownloadPath = assetFile + a.Extension = filepath.Ext(a.DownloadPath) + + assetFileHash := fmt.Sprintf("%s.sha256", assetFile) + stats, err := os.Stat(assetFileHash) + if err != nil && !os.IsNotExist(err) { + return err + } + + if stats != nil { + logrus.Debugf("file already downloaded: %s", assetFile) + return nil + } + + logrus.Debugf("downloading asset: %d", a.KeyID) + + url := fmt.Sprintf("https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x%s", fmt.Sprintf("%X", a.KeyID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return fmt.Errorf("failed to download key: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Check if the request was successful + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to download key: server returned status %s", resp.Status) + } + + hasher := sha256.New() + tmpFile, err := os.Create(assetFile) + if err != nil { + return err + } + defer tmpFile.Close() + + multiWriter := io.MultiWriter(tmpFile, hasher) + + f, err := os.Create(assetFile) + if err != nil { + return err + } + + // Write the asset's content to the temporary file + _, err = io.Copy(multiWriter, resp.Body) + if err != nil { + return err + } + + if _, err := io.Copy(f, resp.Body); err != nil { + return err + } + + logrus.Tracef("hash: %x", hasher.Sum(nil)) + + _ = os.WriteFile(assetFileHash, []byte(fmt.Sprintf("%x", hasher.Sum(nil))), 0600) + a.Hash = string(hasher.Sum(nil)) + + logrus.Tracef("Downloaded asset to: %s", tmpFile.Name()) + + return nil +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 708b17b..6e026ee 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/json" "errors" "fmt" "os" @@ -13,6 +14,8 @@ import ( "github.com/apex/log" "github.com/sirupsen/logrus" + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/ekristen/distillery/pkg/asset" "github.com/ekristen/distillery/pkg/checksum" "github.com/ekristen/distillery/pkg/config" @@ -23,6 +26,11 @@ import ( const ( VersionLatest = "latest" + ChecksumType = "checksum" + + SignatureTypeNone = "none" + SignatureTypeFile = "file" + SignatureTypeChecksum = "checksum" ) type Options struct { @@ -40,6 +48,9 @@ type Provider struct { Signature asset.IAsset Checksum asset.IAsset Key asset.IAsset + + ChecksumType string + SignatureType string } func (p *Provider) GetOS() string { @@ -78,13 +89,14 @@ func (p *Provider) CommonRun(ctx context.Context) error { return nil } -// Discover will attempt to discover and categorize the assets provided -// TODO(ek): split up and refactor this function as it's way too complex -func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo +func (p *Provider) discoverBinary(names []string, version string) error { //nolint:gocyclo + logger := logrus.WithField("discover", "binary") + logger.Tracef("names: %v", names) + fileScoring := map[asset.Type][]string{} fileScored := map[asset.Type][]score.Sorted{} - logrus.Tracef("discover: starting - %d", len(p.Assets)) + logger.Tracef("discover: starting - %d", len(p.Assets)) for _, a := range p.Assets { if _, ok := fileScoring[a.GetType()]; !ok { @@ -94,7 +106,7 @@ func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo } for k, v := range fileScoring { - logrus.Tracef("discover: type: %d, files: %d", k, len(v)) + logger.Tracef("discover: type: %d, files: %d", k, len(v)) } highEnoughScore := false @@ -117,7 +129,8 @@ func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo OS: detectedOS, Arch: arch, Extensions: ext, - Names: names, + Terms: names, + Versions: []string{version}, InvalidOS: p.OSConfig.InvalidOS(), InvalidArch: p.OSConfig.InvalidArchitectures(), }) @@ -127,17 +140,17 @@ func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo if vv.Value >= 40 { highEnoughScore = true } - logrus.Debugf("file scoring sorted ! type: %d, scored: %v", k, vv) + logger.Debugf("file scoring sorted ! type: %d, scored: %v", k, vv) } } } if !highEnoughScore && !p.Options.Settings["no-score-check"].(bool) { - log.Error("no matching asset found, score too low") + logger.Error("no matching asset found, score too low") for _, t := range []asset.Type{asset.Binary, asset.Unknown, asset.Archive} { for _, v := range fileScored[t] { if v.Value < 40 { - log.Errorf("closest matching: %p (%d) (threshold: 40) -- override with --no-score-check", v.Key, v.Value) + logger.Errorf("closest matching: %s (%d) (threshold: 40) -- override with --no-score-check", v.Key, v.Value) return errors.New("no matching asset found, score too low") } } @@ -149,11 +162,11 @@ func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo // Note: we want to look for the best binary by looking at binaries, archives and unknowns for _, t := range []asset.Type{asset.Binary, asset.Archive, asset.Unknown} { if len(fileScored[t]) > 0 { - logrus.Tracef("top scored (%d): %s (%d)", t, fileScored[t][0].Key, fileScored[t][0].Value) + logger.Tracef("top scored (%d): %s (%d)", t, fileScored[t][0].Key, fileScored[t][0].Value) topScored := fileScored[t][0] if topScored.Value < 40 { - logrus.Tracef("skipped > (%d) too low: %s (%d)", t, topScored.Key, topScored.Value) + logger.Tracef("skipped > (%d) too low: %s (%d)", t, topScored.Key, topScored.Value) continue } for _, a := range p.Assets { @@ -173,76 +186,326 @@ func (p *Provider) Discover(names []string) error { //nolint:funlen,gocyclo return errors.New("no binary found") } + return nil +} + +func (p *Provider) discoverChecksum() error { + logger := logrus.WithField("discover", "checksum") + + fileScoring := map[asset.Type][]string{} + fileScored := map[asset.Type][]score.Sorted{} + + logger.Tracef("discover: starting - %d", len(p.Assets)) + + for _, a := range p.Assets { + if _, ok := fileScoring[a.GetType()]; !ok { + fileScoring[a.GetType()] = []string{} + } + fileScoring[a.GetType()] = append(fileScoring[a.GetType()], a.GetName()) + } + + for k, v := range fileScoring { + logger.Tracef("discover: type: %d, files: %d", k, len(v)) + } + // Note: second pass we want to look for everything else, using binary results to help score the remaining assets // THis is for the checksum, signature and key files for k, v := range fileScoring { - if k == asset.Binary || k == asset.Unknown || k == asset.Archive { + if k != asset.Checksum { continue } - detectedOS := p.OSConfig.GetOS() - arch := p.OSConfig.GetArchitectures() - ext := p.OSConfig.GetExtensions() + ext := []string{"sha256", "md5", "sha1", "txt"} + var detectedOS []string + var arch []string - if k == asset.Key { - ext = []string{"key", "pub", "pem"} - detectedOS = []string{} - arch = []string{} - } else if k == asset.Signature { - ext = []string{"sig", "asc"} - detectedOS = []string{} - arch = []string{} - } else if k == asset.Checksum { - ext = []string{"sha256", "md5", "sha1", "txt"} - detectedOS = []string{} - arch = []string{} + if _, ok := fileScored[k]; !ok { + fileScored[k] = []score.Sorted{} } + fileScored[k] = score.Score(v, &score.Options{ + OS: detectedOS, + Arch: arch, + Extensions: ext, + WeightedTerms: map[string]int{ + "checksums": 80, + "SHA512": 50, + "SHA256": 40, + "MD5": 30, + "SHA1": 20, + "SHA": 15, + "SUMS": 10, + }, + InvalidOS: p.OSConfig.InvalidOS(), + InvalidArch: p.OSConfig.InvalidArchitectures(), + }) + + if len(fileScored[k]) > 0 { + for _, vv := range fileScored[k] { + logger.Debugf("file scoring sorted ! type: %d, scored: %v", k, vv) + } + } + } + + // Note: we want to look for the best binary by looking at binaries, archives and unknowns + for _, t := range []asset.Type{asset.Checksum} { + if len(fileScored[t]) > 0 { + logger.Tracef("top scored (%d): %s (%d)", t, fileScored[t][0].Key, fileScored[t][0].Value) + + topScored := fileScored[t][0] + if topScored.Value < 40 { + logger.Tracef("skipped > (%d) too low: %s (%d)", t, topScored.Key, topScored.Value) + continue + } + for _, a := range p.Assets { + if topScored.Key == a.GetName() { + p.Checksum = a + break + } + } + } + + if p.Checksum != nil { + break + } + } + + return nil +} + +func (p *Provider) determineChecksumSigTypes() error { + logger := logrus.WithField("discover", "check-sig-type") + + p.ChecksumType = "none" + if p.Checksum != nil { + p.ChecksumType = p.Checksum.GetChecksumType() + } + + p.SignatureType = SignatureTypeNone + for _, a := range p.Assets { + if a.GetType() != asset.Signature { + continue + } + + if p.SignatureType == SignatureTypeFile { + break + } + + if a.GetParentType() == asset.Binary || a.GetParentType() == asset.Archive || a.GetParentType() == asset.Unknown { + p.SignatureType = SignatureTypeFile + } else if a.GetParentType() == asset.Checksum { + p.SignatureType = SignatureTypeChecksum + } + } + + logger.Tracef("checksum type: %s", p.ChecksumType) + logger.Tracef("signature type: %s", p.SignatureType) + + return nil +} + +func (p *Provider) discoverSignature(version string) error { //nolint:gocyclo + logger := logrus.WithField("discover", "signature") + + fileScoring := map[asset.Type][]string{} + fileScored := map[asset.Type][]score.Sorted{} + + logger.Tracef("discover: starting - %d", len(p.Assets)) + + for _, a := range p.Assets { + if _, ok := fileScoring[a.GetType()]; !ok { + fileScoring[a.GetType()] = []string{} + } + fileScoring[a.GetType()] = append(fileScoring[a.GetType()], a.GetName()) + } + + for k, v := range fileScoring { + logger.Tracef("discover: type: %d, files: %d", k, len(v)) + } + + var names []string + if p.SignatureType == SignatureTypeChecksum { + names = append(names, p.Checksum.GetName()) + for _, ext := range []string{"sig", "asc"} { + names = append(names, fmt.Sprintf("%s.%s", p.Checksum.GetName(), ext)) + } + } else if p.SignatureType == SignatureTypeFile { + names = append(names, p.Binary.GetName()) + for _, ext := range []string{"sig", "asc"} { + names = append(names, fmt.Sprintf("%s.%s", p.Binary.GetName(), ext)) + } + } + + // Note: second pass we want to look for everything else, using binary results to help score the remaining assets + // This is for the checksum, signature and key files + for k, v := range fileScoring { + if k != asset.Signature { + continue + } + + ext := []string{"sig", "asc", "sig.asc", "gpg", "keyless.sig"} + var detectedOS []string + var arch []string + if _, ok := fileScored[k]; !ok { fileScored[k] = []score.Sorted{} } + logger.Tracef("names: %v", names) + fileScored[k] = score.Score(v, &score.Options{ OS: detectedOS, Arch: arch, Extensions: ext, - Names: []string{strings.ReplaceAll(p.Binary.GetName(), filepath.Ext(p.Binary.GetName()), "")}, + Names: names, + Versions: []string{version}, InvalidOS: p.OSConfig.InvalidOS(), InvalidArch: p.OSConfig.InvalidArchitectures(), }) if len(fileScored[k]) > 0 { - logrus.Debugf("file scoring sorted ! type: %d, scored: %v", k, fileScored[k][0]) + for _, vv := range fileScored[k] { + logger.Debugf("file scoring sorted ! type: %d, scored: %v", k, vv) + } } } + // Note: we want to look for the best binary by looking at binaries, archives and unknowns + for _, t := range []asset.Type{asset.Signature} { + if len(fileScored[t]) > 0 { + logger.Tracef("top scored (%d): %s (%d)", t, fileScored[t][0].Key, fileScored[t][0].Value) + + topScored := fileScored[t][0] + if topScored.Value < 40 { + logger.Tracef("skipped > (%d) too low: %s (%d)", t, topScored.Key, topScored.Value) + continue + } + for _, a := range p.Assets { + if topScored.Key == a.GetName() { + p.Signature = a + p.Key = a.GetMatchedAsset() + break + } + } + } + + if p.Signature != nil { + break + } + } + + return nil +} + +// TODO: refactor into smaller functions for testing +func (p *Provider) discoverMatch() error { //nolint:gocyclo + logger := logrus.WithField("discover", "match") + + // Match keys to signatures. for _, a := range p.Assets { - for k, v := range fileScored { - vv := v[0] + if a.GetType() != asset.Signature { + continue + } + + if a.GetMatchedAsset() != nil { + continue + } - if a.GetType() == asset.Checksum && a.GetType() == k && a.GetName() == vv.Key { //nolint:gocritic - p.Checksum = a + for _, aa := range p.Assets { + if aa.GetType() != asset.Key { + continue } - if a.GetType() == asset.Signature && a.GetType() == k && a.GetName() == vv.Key { //nolint:gocritic - p.Signature = a + + childS := strings.TrimSuffix(aa.GetName(), filepath.Ext(aa.GetName())) + parentS := strings.TrimSuffix(a.GetName(), filepath.Ext(a.GetName())) + + if strings.EqualFold(childS, parentS) { + logger.Tracef("matched key: %s to signature: %s", aa.GetName(), a.GetName()) + a.SetMatchedAsset(aa) + aa.SetMatchedAsset(a) + break } - if a.GetType() == asset.Key && a.GetType() == k && a.GetName() == vv.Key { //nolint:gocritic - p.Key = a + } + } + + // Match remaining keys to signatures, hopefully there's only a single key remaining + // TODO: what to do if there are multiple keys remaining? (Maybe support multiple matched???) + // Use Case: Keyless vs Keyed signing, cosign does both. The keyed file is used for multiple files. + for _, a := range p.Assets { + if a.GetType() != asset.Key { + continue + } + + if a.GetMatchedAsset() != nil { + continue + } + + logger.Tracef("unmatched key: %s", a.GetName()) + + for _, b := range p.Assets { + if b.GetType() != asset.Signature { + continue + } + + if b.GetMatchedAsset() != nil { + continue } + + b.SetMatchedAsset(a) + logger.Tracef("matched key: %s to signature: %s", a.GetName(), b.GetName()) } } - if p.Binary != nil { - logrus.Tracef("best binary: %s", p.Binary.GetName()) + for _, a := range p.Assets { + if a.GetType() != asset.Signature { + continue + } + + if a.GetMatchedAsset() != nil { + continue + } + + if !strings.HasSuffix(a.GetName(), ".asc") { + continue + } + + keyName := strings.ReplaceAll(a.GetName(), ".asc", ".pub") + + gpgAsset := &GPGAsset{ + Asset: asset.New(keyName, "", p.GetOS(), p.GetArch(), ""), + } + + gpgAsset.SetMatchedAsset(a) + a.SetMatchedAsset(gpgAsset) + + p.Assets = append(p.Assets, gpgAsset) + + log.Info("gpg detected will fetch public key") } - if p.Checksum != nil { - logrus.Tracef("best checksum: %s", p.Checksum.GetName()) + + return nil +} + +// Discover will attempt to discover and categorize the assets provided +func (p *Provider) Discover(names []string, version string) error { + if err := p.discoverMatch(); err != nil { + return err } - if p.Signature != nil { - logrus.Tracef("best signature: %s", p.Signature.GetName()) + + if err := p.discoverBinary(names, version); err != nil { + return err } - if p.Key != nil { - logrus.Tracef("best key: %s", p.Key.GetName()) + + if err := p.discoverChecksum(); err != nil { + return err + } + + if err := p.determineChecksumSigTypes(); err != nil { + return err + } + + if err := p.discoverSignature(version); err != nil { + return err } return nil @@ -286,41 +549,144 @@ func (p *Provider) Verify() error { } func (p *Provider) verifySignature() error { - if true { - log.Debug("skipping signature verification") + if p.Signature == nil { + log.Warn("skipping signature verification (no signature)") return nil } - logrus.Info("verifying signature") + // TODO: better pgp detection + if strings.HasSuffix(p.Signature.GetName(), ".asc") { + return p.verifyGPGSignature() + } + + return p.verifyCosignSignature() +} + +func (p *Provider) verifyGPGSignature() error { + var filePath string + if p.SignatureType == "checksum" { + filePath = p.Checksum.GetFilePath() + } else { + filePath = p.Binary.GetFilePath() + } + + publicKeyPath := p.Key.GetFilePath() + signaturePath := p.Signature.GetFilePath() - cosignFileContent, err := os.ReadFile(p.Checksum.GetFilePath()) + publicKeyContent, err := os.Open(publicKeyPath) if err != nil { return err } - publicKeyContentEncoded, err := os.ReadFile(p.Key.GetFilePath()) + signatureContent, err := os.ReadFile(signaturePath) if err != nil { - return err + return fmt.Errorf("failed to read signature file: %w", err) } - publicKeyContent, err := base64.StdEncoding.DecodeString(string(publicKeyContentEncoded)) + fileContent, err := os.ReadFile(filePath) if err != nil { - return err + return fmt.Errorf("failed to read file to be verified: %w", err) } - pubKey, err := cosign.ParsePublicKey(publicKeyContent) + keyObj, err := crypto.NewKeyFromArmoredReader(publicKeyContent) if err != nil { - return err + return fmt.Errorf("failed to parse public key: %w", err) + } + + keyRing, err := crypto.NewKeyRing(keyObj) + if err != nil { + return fmt.Errorf("failed to create keyring: %w", err) + } + + message := crypto.NewPlainMessage(fileContent) + signature, err := crypto.NewPGPSignatureFromArmored(string(signatureContent)) + if err != nil { + return fmt.Errorf("failed to parse signature: %w", err) + } + + err = keyRing.VerifyDetached(message, signature, crypto.GetUnixTime()) + if err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + + log.Info("signature verified") + + return nil +} + +// TODO: refactor and clean up for the different signature verification methods +func (p *Provider) verifyCosignSignature() error { //nolint:gocyclo + var bundle *cosign.Bundle + if p.Key == nil { + sigData, err := os.ReadFile(p.Signature.GetFilePath()) + if err != nil { + return err + } + if err := json.Unmarshal(sigData, &bundle); err != nil { + log.WithError(err).Trace("unable to parse json for bundle signature") + } + + if bundle == nil { + log.Warn("skipping signature verification (no key)") + return nil + } + } + + logrus.Trace("verifying signature") + + var fileContent []byte + var err error + if p.SignatureType == "checksum" { + logrus.Trace("verifying checksum signature", p.Checksum.GetName()) + fileContent, err = os.ReadFile(p.Checksum.GetFilePath()) + if err != nil { + return err + } + } else { + logrus.Trace("verifying binary signature") + fileContent, err = os.ReadFile(p.Binary.GetFilePath()) + if err != nil { + return err + } } - fmt.Printf("Public Key: %+v\n", pubKey) + var sigData []byte + var publicKeyContentEncoded []byte + if p.Key != nil { + logrus.Trace("key file name: ", p.Key.GetName()) + publicKeyContentEncoded, err = os.ReadFile(p.Key.GetFilePath()) + if err != nil { + return err + } - sigData, err := os.ReadFile(p.Signature.GetFilePath()) + sigData, err = os.ReadFile(p.Signature.GetFilePath()) + if err != nil { + return err + } + } else if bundle != nil { + publicKeyContentEncoded = []byte(bundle.Certificate) + sigData = []byte(bundle.Signature) + } + + publicKeyContent, err := base64.StdEncoding.DecodeString(string(publicKeyContentEncoded)) + if err != nil { + if errors.Is(err, base64.CorruptInputError(0)) { + publicKeyContent = publicKeyContentEncoded + } else { + return err + } + } + + pubKey, err := cosign.ParsePublicKey(publicKeyContent) if err != nil { return err } - valid, err := cosign.VerifySignature(pubKey, cosignFileContent, sigData) + logrus.Trace("signature file name: ", p.Signature.GetName()) + + dataHash := cosign.HashData(fileContent) + + valid, err := cosign.VerifySignature(pubKey, dataHash, sigData) if err != nil { return err } @@ -329,6 +695,8 @@ func (p *Provider) verifySignature() error { return errors.New("unable to validate signature") } + log.Info("signature verified") + return nil } diff --git a/pkg/provider/provider_test.go b/pkg/provider/provider_test.go index adc3e35..fa455c6 100644 --- a/pkg/provider/provider_test.go +++ b/pkg/provider/provider_test.go @@ -2,6 +2,8 @@ package provider_test import ( "fmt" + "path/filepath" + "strings" "testing" "github.com/sirupsen/logrus" @@ -18,6 +20,7 @@ func init() { type testSourceDiscoverTest struct { name string + version string filenames []string matrix []testSourceDiscoverMatrix } @@ -25,20 +28,23 @@ type testSourceDiscoverTest struct { type testSourceDiscoverMatrix struct { os string arch string + version string expected testSourceDiscoverExpected } type testSourceDiscoverExpected struct { error string binary string - signature string checksum string + signature string + key string } func TestSourceDiscover(t *testing.T) { cases := []testSourceDiscoverTest{ { - name: "pulumi", + name: "pulumi", + version: "3.133.0", filenames: []string{ "B3SUMS", "B3SUMS.sig", @@ -63,10 +69,11 @@ func TestSourceDiscover(t *testing.T) { "SHA512SUMS", "SHA512SUMS.sig", }, - matrix: []testSourceDiscoverMatrix{ //nolint:dupl + matrix: []testSourceDiscoverMatrix{ { - os: "darwin", - arch: "amd64", + os: "darwin", + arch: "amd64", + version: "3.133.0", expected: testSourceDiscoverExpected{ binary: "pulumi-v3.133.0-darwin-x64.tar.gz", signature: "pulumi-v3.133.0-darwin-x64.tar.gz.sig", @@ -74,8 +81,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "darwin", - arch: "arm64", + os: "darwin", + arch: "arm64", + version: "3.133.0", expected: testSourceDiscoverExpected{ binary: "pulumi-v3.133.0-darwin-arm64.tar.gz", signature: "pulumi-v3.133.0-darwin-arm64.tar.gz.sig", @@ -83,8 +91,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "linux", - arch: "amd64", + os: "linux", + arch: "amd64", + version: "3.133.0", expected: testSourceDiscoverExpected{ binary: "pulumi-v3.133.0-linux-x64.tar.gz", signature: "pulumi-v3.133.0-linux-x64.tar.gz.sig", @@ -92,8 +101,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "linux", - arch: "arm64", + os: "linux", + arch: "arm64", + version: "3.133.0", expected: testSourceDiscoverExpected{ binary: "pulumi-v3.133.0-linux-arm64.tar.gz", signature: "pulumi-v3.133.0-linux-arm64.tar.gz.sig", @@ -101,8 +111,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "windows", - arch: "amd64", + os: "windows", + arch: "amd64", + version: "3.133.0", expected: testSourceDiscoverExpected{ binary: "pulumi-v3.133.0-windows-x64.zip", signature: "pulumi-v3.133.0-windows-x64.zip.sig", @@ -112,7 +123,8 @@ func TestSourceDiscover(t *testing.T) { }, }, { - name: "cosign", + name: "cosign", + version: "2.4.0", filenames: []string{ "cosign-2.4.0-1.aarch64.rpm", "cosign-2.4.0-1.aarch64.rpm-keyless.pem", @@ -228,56 +240,67 @@ func TestSourceDiscover(t *testing.T) { "cosign_checksums.txt-keyless.sig", "release-cosign.pub", }, - matrix: []testSourceDiscoverMatrix{ //nolint:dupl + matrix: []testSourceDiscoverMatrix{ { - os: "darwin", - arch: "amd64", + os: "darwin", + arch: "amd64", + version: "2.4.0", expected: testSourceDiscoverExpected{ binary: "cosign-darwin-amd64", - signature: "cosign-darwin-amd64.sig", checksum: "cosign_checksums.txt", + signature: "cosign-darwin-amd64.sig", + key: "release-cosign.pub", }, }, { - os: "darwin", - arch: "arm64", + os: "darwin", + arch: "arm64", + version: "2.4.0", expected: testSourceDiscoverExpected{ binary: "cosign-darwin-arm64", - signature: "cosign-darwin-arm64.sig", checksum: "cosign_checksums.txt", + signature: "cosign-darwin-arm64.sig", + key: "release-cosign.pub", }, }, { - os: "linux", - arch: "amd64", + os: "linux", + arch: "amd64", + version: "2.4.0", expected: testSourceDiscoverExpected{ binary: "cosign-linux-amd64", - signature: "cosign-linux-amd64.sig", checksum: "cosign_checksums.txt", + signature: "cosign-linux-amd64.sig", + key: "release-cosign.pub", }, }, { - os: "linux", - arch: "arm64", + os: "linux", + arch: "arm64", + version: "2.4.0", expected: testSourceDiscoverExpected{ binary: "cosign-linux-arm64", - signature: "cosign-linux-arm64.sig", checksum: "cosign_checksums.txt", + signature: "cosign-linux-arm64.sig", + key: "release-cosign.pub", }, }, { - os: "windows", - arch: "amd64", + os: "windows", + arch: "amd64", + version: "2.4.0", expected: testSourceDiscoverExpected{ binary: "cosign-windows-amd64.exe", - signature: "cosign-windows-amd64.exe.sig", checksum: "cosign_checksums.txt", + signature: "cosign-windows-amd64.exe.sig", + key: "release-cosign.pub", }, }, }, }, { - name: "acorn", + name: "acorn", + version: "0.10.1", filenames: []string{ "acorn-v0.10.1-linux-amd64.tar.gz", "acorn-v0.10.1-linux-arm64.tar.gz", @@ -287,22 +310,25 @@ func TestSourceDiscover(t *testing.T) { }, matrix: []testSourceDiscoverMatrix{ { - os: "darwin", - arch: "amd64", + os: "darwin", + arch: "amd64", + version: "0.10.1", expected: testSourceDiscoverExpected{ binary: "acorn-v0.10.1-macOS-universal.tar.gz", }, }, { - os: "darwin", - arch: "arm64", + os: "darwin", + arch: "arm64", + version: "0.10.1", expected: testSourceDiscoverExpected{ binary: "acorn-v0.10.1-macOS-universal.tar.gz", }, }, { - os: "linux", - arch: "amd64", + os: "linux", + arch: "amd64", + version: "0.10.1", expected: testSourceDiscoverExpected{ binary: "acorn-v0.10.1-linux-amd64.tar.gz", signature: "", @@ -310,8 +336,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "linux", - arch: "arm64", + os: "linux", + arch: "arm64", + version: "0.10.1", expected: testSourceDiscoverExpected{ binary: "acorn-v0.10.1-linux-arm64.tar.gz", signature: "", @@ -319,8 +346,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "windows", - arch: "amd64", + os: "windows", + arch: "amd64", + version: "0.10.1", expected: testSourceDiscoverExpected{ binary: "acorn-v0.10.1-windows-amd64.zip", signature: "", @@ -330,7 +358,8 @@ func TestSourceDiscover(t *testing.T) { }, }, { - name: "nerdctl", + name: "nerdctl", + version: "1.7.7", filenames: []string{ "nerdctl-1.7.7-freebsd-amd64.tar.gz", "nerdctl-1.7.7-go-mod-vendor.tar.gz", @@ -348,8 +377,9 @@ func TestSourceDiscover(t *testing.T) { }, matrix: []testSourceDiscoverMatrix{ { - os: "darwin", - arch: "amd64", + os: "darwin", + arch: "amd64", + version: "1.7.7", expected: testSourceDiscoverExpected{ error: "no matching asset found, score too low", binary: "", @@ -358,8 +388,9 @@ func TestSourceDiscover(t *testing.T) { }, }, { - os: "linux", - arch: "arm64", + os: "linux", + arch: "arm64", + version: "1.7.7", expected: testSourceDiscoverExpected{ binary: "nerdctl-1.7.7-linux-arm64.tar.gz", signature: "SHA256SUMS.asc", @@ -368,12 +399,91 @@ func TestSourceDiscover(t *testing.T) { }, }, }, + { + name: "distillery", + version: "1.0.0-beta.5", + filenames: []string{ + "checksums.txt", + "checksums.txt.pem", + "checksums.txt.sig", + "distillery-v1.0.0-beta.5-darwin-amd64.tar.gz", + "distillery-v1.0.0-beta.5-darwin-amd64.tar.gz.sbom.json", + "distillery-v1.0.0-beta.5-darwin-amd64.tar.gz.sbom.json.pem", + "distillery-v1.0.0-beta.5-darwin-amd64.tar.gz.sbom.json.sig", + "distillery-v1.0.0-beta.5-darwin-arm64.tar.gz", + "distillery-v1.0.0-beta.5-darwin-arm64.tar.gz.sbom.json", + "distillery-v1.0.0-beta.5-darwin-arm64.tar.gz.sbom.json.pem", + "distillery-v1.0.0-beta.5-darwin-arm64.tar.gz.sbom.json.sig", + "distillery-v1.0.0-beta.5-freebsd-amd64.tar.gz", + "distillery-v1.0.0-beta.5-freebsd-amd64.tar.gz.sbom.json", + "distillery-v1.0.0-beta.5-freebsd-amd64.tar.gz.sbom.json.pem", + "distillery-v1.0.0-beta.5-freebsd-amd64.tar.gz.sbom.json.sig", + "distillery-v1.0.0-beta.5-freebsd-arm64.tar.gz", + "distillery-v1.0.0-beta.5-freebsd-arm64.tar.gz.sbom.json", + "distillery-v1.0.0-beta.5-freebsd-arm64.tar.gz.sbom.json.pem", + "distillery-v1.0.0-beta.5-freebsd-arm64.tar.gz.sbom.json.sig", + "distillery-v1.0.0-beta.5-linux-amd64.tar.gz", + "distillery-v1.0.0-beta.5-linux-amd64.tar.gz.sbom.json", + "distillery-v1.0.0-beta.5-linux-amd64.tar.gz.sbom.json.pem", + "distillery-v1.0.0-beta.5-linux-amd64.tar.gz.sbom.json.sig", + "distillery-v1.0.0-beta.5-linux-arm64.tar.gz", + "distillery-v1.0.0-beta.5-linux-arm64.tar.gz.sbom.json", + "distillery-v1.0.0-beta.5-linux-arm64.tar.gz.sbom.json.pem", + "distillery-v1.0.0-beta.5-linux-arm64.tar.gz.sbom.json.sig", + "distillery-v1.0.0-beta.5-windows-amd64.zip", + "distillery-v1.0.0-beta.5-windows-amd64.zip.sbom.json", + "distillery-v1.0.0-beta.5-windows-amd64.zip.sbom.json.pem", + "distillery-v1.0.0-beta.5-windows-amd64.zip.sbom.json.sig", + "distillery-v1.0.0-beta.5-windows-arm64.zip", + "distillery-v1.0.0-beta.5-windows-arm64.zip.sbom.json", + "distillery-v1.0.0-beta.5-windows-arm64.zip.sbom.json.pem", + "distillery-v1.0.0-beta.5-windows-arm64.zip.sbom.json.sig", + }, + matrix: []testSourceDiscoverMatrix{ + { + os: "darwin", + arch: "amd64", + version: "1.0.0-beta.5", + expected: testSourceDiscoverExpected{ + binary: "distillery-v1.0.0-beta.5-darwin-amd64.tar.gz", + checksum: "checksums.txt", + signature: "checksums.txt.sig", + key: "checksums.txt.pem", + }, + }, + }, + }, + { + name: "gitlab-runner", + version: "16.11.4", + filenames: []string{ + "release.sha256.asc", + "release.sha256", + "gitlab-runner-linux-amd64", + "gitlab-runner-linux-arm64", + "gitlab-runner-darwin-arm64", + "gitlab-runner-darwin-amd64", + }, + matrix: []testSourceDiscoverMatrix{ + { + os: "darwin", + arch: "amd64", + version: "16.11.4", + expected: testSourceDiscoverExpected{ + binary: "gitlab-runner-darwin-amd64", + checksum: "release.sha256", + signature: "release.sha256.asc", + key: "release.sha256.pub", + }, + }, + }, + }, } t.Parallel() for _, tc := range cases { for _, m := range tc.matrix { - t.Run(fmt.Sprintf("%s-%s-%s", tc.name, m.os, m.arch), func(t *testing.T) { + t.Run(fmt.Sprintf("%s-%s-%s-%s", tc.name, m.version, m.os, m.arch), func(t *testing.T) { var assets []asset.IAsset for _, filename := range tc.filenames { newA := &asset.Asset{ @@ -381,8 +491,11 @@ func TestSourceDiscover(t *testing.T) { DisplayName: filename, OS: m.os, Arch: m.arch, + Version: m.version, } - newA.Classify() + newA.Type = newA.Classify(newA.Name) + newA.ParentType = newA.Classify(strings.ReplaceAll(newA.Name, filepath.Ext(newA.Name), "")) + assets = append(assets, newA) } @@ -398,7 +511,7 @@ func TestSourceDiscover(t *testing.T) { Assets: assets, } - err := testSource.Discover([]string{tc.name}) + err := testSource.Discover([]string{tc.name}, tc.version) if m.expected.error != "" { assert.EqualError(t, err, m.expected.error) return @@ -409,11 +522,26 @@ func TestSourceDiscover(t *testing.T) { if m.expected.binary != "" { assert.Equal(t, m.expected.binary, testSource.Binary.GetName(), "expected binary") } + if m.expected.checksum != "" { + if testSource.Checksum != nil { + assert.Equal(t, m.expected.checksum, testSource.Checksum.GetName(), "expected checksum") + } else { + t.Error("expected checksum and missing") + } + } if m.expected.signature != "" { - assert.Equal(t, m.expected.signature, testSource.Signature.GetName(), "expected signature") + if testSource.Signature != nil { + assert.Equal(t, m.expected.signature, testSource.Signature.GetName(), "expected signature") + } else { + t.Error("expected signature and missing") + } } - if m.expected.checksum != "" { - assert.Equal(t, m.expected.checksum, testSource.Checksum.GetName(), "expected checksum") + if m.expected.key != "" { + if testSource.Key != nil { + assert.Equal(t, m.expected.key, testSource.Key.GetName(), "expected key") + } else { + t.Error("expected key and missing") + } } }) } diff --git a/pkg/score/score.go b/pkg/score/score.go index 1394cd3..bfffc7e 100644 --- a/pkg/score/score.go +++ b/pkg/score/score.go @@ -1,11 +1,13 @@ package score import ( + "fmt" "path/filepath" "sort" "strings" "github.com/h2non/filetype" + "github.com/sirupsen/logrus" ) type Options struct { @@ -13,18 +15,48 @@ type Options struct { Arch []string Extensions []string Names []string + Versions []string + Terms []string + WeightedTerms map[string]int InvalidOS []string InvalidArch []string InvalidExtensions []string } -func Score(names []string, opts *Options) []Sorted { +func (o *Options) GetAllStrings() []string { + var allStrings []string + allStrings = append(allStrings, o.OS...) + allStrings = append(allStrings, o.Arch...) + allStrings = append(allStrings, o.Terms...) + allStrings = append(allStrings, o.Names...) + allStrings = append(allStrings, o.Versions...) + + for _, key := range o.Versions { + allStrings = append(allStrings, fmt.Sprintf("v%s", key)) + } + + return allStrings +} + +func Score(names []string, opts *Options) []Sorted { //nolint:gocyclo + logger := logrus.WithField("function", "score") + logger.Tracef("names: %v", names) + var scores = make(map[string]int) for _, name := range names { var score int var scoringValues = make(map[string]int) + for _, name1 := range opts.Names { + if name1 == name { + scores = map[string]int{ + name: 200, + } + return sortMapByValue(scores) + } + } + // Note: if it has the word "update" in it, we want to deprioritize it as it's likely an update binary from // a rust or go binary distribution scoringValues["update"] = -100 @@ -39,8 +71,8 @@ func Score(names []string, opts *Options) []Sorted { for _, ext := range opts.Extensions { scoringValues[strings.ToLower(ext)] = 20 } - for _, name1 := range opts.Names { - scoringValues[strings.ToLower(name1)] = 10 + for _, term := range opts.Terms { + scoringValues[strings.ToLower(term)] = 10 } for _, os1 := range opts.InvalidOS { @@ -53,6 +85,10 @@ func Score(names []string, opts *Options) []Sorted { scoringValues[strings.ToLower(ext)] = -20 } + for term, weight := range opts.WeightedTerms { + scoringValues[strings.ToLower(term)] = weight + } + for keyMatch, keyScore := range scoringValues { if keyScore == 20 { // handle extensions special if ext := strings.TrimPrefix(filepath.Ext(strings.ToLower(name)), "."); ext != "" { @@ -70,12 +106,63 @@ func Score(names []string, opts *Options) []Sorted { } } - scores[name] = score + scores[name] = score + calculateAccuracyScore(name, opts.GetAllStrings()) } return sortMapByValue(scores) } +func removeExtension(filename string) string { + for { + newFilename := filename + newExt := filepath.Ext(newFilename) + + newFilename = strings.TrimSuffix(newFilename, newExt) + + if newFilename == filename { + break + } + + filename = newFilename + } + + return filename +} + +func calculateAccuracyScore(filename string, knownTerms []string) int { + filename = removeExtension(filename) // Remove the file extension + + // Split the filename by dashes and dots to get individual terms + terms := strings.FieldsFunc(filename, func(r rune) bool { + return r == '-' || r == '_' + }) + + // Initialize the score + score := 0 + + // Create a map for quick lookup of known terms + knownMap := make(map[string]bool) + for _, term := range knownTerms { + knownMap[term] = true + } + + // Check each term in the filename + for _, term := range terms { + if filename == term { + logrus.WithField("filename", filename).Trace("adding point for term: ", term) + score += 10 // Add points for a direct match + } else if knownMap[term] { + logrus.WithField("filename", filename).Trace("adding point for term: ", term) + score += 2 // Add point for a correct match + } else { + logrus.WithField("filename", filename).Trace("subtracting point for term: ", term) + score += -5 // Add a larger penalty for an unknown term + } + } + + return score +} + type Sorted struct { Key string Value int @@ -94,6 +181,9 @@ func sortMapByValue(m map[string]int) []Sorted { // Sort the slice based on the values in descending order sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Value == sorted[j].Value { + return sorted[i].Key < sorted[j].Key + } return sorted[i].Value > sorted[j].Value }) diff --git a/pkg/score/score_test.go b/pkg/score/score_test.go index 68e7cce..0fcbeeb 100644 --- a/pkg/score/score_test.go +++ b/pkg/score/score_test.go @@ -11,6 +11,7 @@ import ( func init() { logrus.SetLevel(logrus.TraceLevel) + logrus.SetReportCaller(true) } func TestScore(t *testing.T) { @@ -19,6 +20,7 @@ func TestScore(t *testing.T) { cases := []struct { name string names []string + terms []string opts *Options expected []Sorted }{ @@ -33,7 +35,7 @@ func TestScore(t *testing.T) { expected: []Sorted{ { Key: "dist-linux-amd64.deb", - Value: 70, + Value: 69, }, }, }, @@ -58,7 +60,7 @@ func TestScore(t *testing.T) { expected: []Sorted{ { Key: "dist-linux-amd64", - Value: 70, + Value: 69, }, }, }, @@ -73,12 +75,12 @@ func TestScore(t *testing.T) { Extensions: []string{ types.Unknown.Extension, }, - Names: []string{"something"}, + Terms: []string{"something"}, }, expected: []Sorted{ { Key: "something-linux", - Value: 10, + Value: 7, }, }, }, @@ -91,12 +93,12 @@ func TestScore(t *testing.T) { OS: []string{"linux"}, Arch: []string{"amd64"}, Extensions: []string{"sig"}, - Names: []string{"dist"}, + Terms: []string{"dist"}, }, expected: []Sorted{ { Key: "dist-linux-amd64.sig", - Value: 100, + Value: 106, }, }, }, @@ -113,7 +115,7 @@ func TestScore(t *testing.T) { expected: []Sorted{ { Key: "dist-linux-amd64.pem", - Value: 110, + Value: 109, }, }, }, @@ -128,22 +130,22 @@ func TestScore(t *testing.T) { OS: []string{}, Arch: []string{}, Extensions: []string{"txt"}, - Names: []string{ + Terms: []string{ "checksums", }, }, expected: []Sorted{ { Key: "checksums.txt", - Value: 30, + Value: 40, }, { Key: "SHA256SUMS", - Value: 0, + Value: 10, }, { Key: "SHASUMS", - Value: 0, + Value: 10, }, }, }, @@ -158,7 +160,7 @@ func TestScore(t *testing.T) { OS: []string{"windows"}, Arch: []string{"arm64"}, Extensions: []string{"exe"}, - Names: []string{ + Terms: []string{ "dist", }, InvalidOS: []string{"linux", "darwin"}, @@ -167,15 +169,15 @@ func TestScore(t *testing.T) { expected: []Sorted{ { Key: "dist-windows-arm64.exe", - Value: 100, // os, arch, ext, name match + Value: 106, // os, arch, ext, name match }, { Key: "dist-linux-amd64", - Value: -60, // invalid os and arch + Value: -68, // invalid os and arch }, { Key: "dist-darwin-amd64", - Value: -60, // invalid os and arch + Value: -68, // invalid os and arch }, }, }, @@ -189,7 +191,7 @@ func TestScore(t *testing.T) { OS: []string{"linux"}, Arch: []string{"amd64"}, Extensions: []string{""}, - Names: []string{ + Terms: []string{ "dist", }, InvalidOS: []string{"windows"}, @@ -198,11 +200,49 @@ func TestScore(t *testing.T) { expected: []Sorted{ { Key: "dist-linux-amd64", - Value: 80, // os, arch, name match + Value: 86, // os, arch, name match }, { Key: "dist-windows-amd64.exe", - Value: -20, // invalid extension and os + Value: -21, // invalid extension and os + }, + }, + }, + { + name: "better-match", + names: []string{ + "nerdctl-1.7.7-linux-arm64.tar.gz", + "nerdctl-1.7.7-linux-amd64.tar.gz", + "nerdctl-full-1.7.7-linux-amd64.tar.gz", + "nerdctl-full-1.7.7-linux-arm64.tar.gz", + }, + opts: &Options{ + OS: []string{"linux"}, + Arch: []string{"amd64"}, + Versions: []string{"1.7.7"}, + Extensions: []string{""}, + Terms: []string{ + "nerdctl", + }, + InvalidOS: []string{"windows"}, + InvalidExtensions: []string{"exe"}, + }, + expected: []Sorted{ + { + Key: "nerdctl-1.7.7-linux-amd64.tar.gz", + Value: 77, + }, + { + Key: "nerdctl-full-1.7.7-linux-amd64.tar.gz", + Value: 72, + }, + { + Key: "nerdctl-1.7.7-linux-arm64.tar.gz", + Value: 47, + }, + { + Key: "nerdctl-full-1.7.7-linux-arm64.tar.gz", + Value: 42, }, }, }, diff --git a/pkg/source/github.go b/pkg/source/github.go index 94198fc..a1a7e71 100644 --- a/pkg/source/github.go +++ b/pkg/source/github.go @@ -59,7 +59,7 @@ func (s *GitHub) Run(ctx context.Context) error { } // this is from the Provider struct - if err := s.Discover([]string{s.Repo}); err != nil { + if err := s.Discover([]string{s.Repo}, s.Version); err != nil { return err } diff --git a/pkg/source/gitlab.go b/pkg/source/gitlab.go index b7ef659..c486c37 100644 --- a/pkg/source/gitlab.go +++ b/pkg/source/gitlab.go @@ -91,7 +91,7 @@ func (s *GitLab) Run(ctx context.Context) error { return err } - if err := s.Discover([]string{s.Repo}); err != nil { + if err := s.Discover([]string{s.Repo}, s.Version); err != nil { return err } diff --git a/pkg/source/hashicorp.go b/pkg/source/hashicorp.go index a1afbc8..45accfd 100644 --- a/pkg/source/hashicorp.go +++ b/pkg/source/hashicorp.go @@ -119,7 +119,7 @@ func (s *Hashicorp) Run(ctx context.Context) error { return err } - if err := s.Discover([]string{s.Repo}); err != nil { + if err := s.Discover([]string{s.Repo}, s.Version); err != nil { return err } diff --git a/pkg/source/homebrew.go b/pkg/source/homebrew.go index a939b6a..1545fe5 100644 --- a/pkg/source/homebrew.go +++ b/pkg/source/homebrew.go @@ -99,7 +99,7 @@ func (s *Homebrew) Run(ctx context.Context) error { return err } - if err := s.Discover([]string{s.Formula}); err != nil { + if err := s.Discover([]string{s.Formula}, s.Version); err != nil { return err } diff --git a/pkg/source/kubernetes.go b/pkg/source/kubernetes.go index 1266ed1..87561e9 100644 --- a/pkg/source/kubernetes.go +++ b/pkg/source/kubernetes.go @@ -75,6 +75,16 @@ func (s *Kubernetes) GetReleaseAssets(_ context.Context) error { Kubernetes: s, URL: fmt.Sprintf("https://dl.k8s.io/release/v%s/bin/%s/%s/%s.sha256", s.Version, s.GetOS(), s.GetArch(), s.AppName), + }, &KubernetesAsset{ + Asset: asset.New(binName+".sig", "", s.GetOS(), s.GetArch(), s.Version), + Kubernetes: s, + URL: fmt.Sprintf("https://dl.k8s.io/release/v%s/bin/%s/%s/%s.sig", + s.Version, s.GetOS(), s.GetArch(), s.AppName), + }, &KubernetesAsset{ + Asset: asset.New(binName+".cert", "", s.GetOS(), s.GetArch(), s.Version), + Kubernetes: s, + URL: fmt.Sprintf("https://dl.k8s.io/release/v%s/bin/%s/%s/%s.cert", + s.Version, s.GetOS(), s.GetArch(), s.AppName), }) return nil @@ -86,7 +96,7 @@ func (s *Kubernetes) Run(ctx context.Context) error { } // this is from the Provider struct - if err := s.Discover([]string{s.Repo}); err != nil { + if err := s.Discover([]string{s.Repo}, s.Version); err != nil { return err }