├── .github ├── dependabot.yaml └── workflows │ ├── release.yaml │ └── test.yaml ├── .goreleaser.yaml ├── .ko.yaml ├── LICENSE ├── README.md ├── cmd ├── root.go └── values.go ├── go.mod ├── go.sum ├── internal ├── bom │ ├── bom.go │ ├── cyclonedx.go │ ├── cyclonedx_test.go │ ├── purl.go │ ├── purl_test.go │ ├── syft.go │ ├── syft_test.go │ └── testdata │ │ ├── cdx.json │ │ ├── cdx.json.invalid │ │ ├── cdx.xml │ │ ├── cdx.xml.invalid │ │ ├── syft.json │ │ └── syft.json.invalid ├── cache │ ├── cache.go │ ├── options.go │ ├── scorecard_client.go │ ├── scorecard_client_test.go │ ├── sqlite.go │ └── sqlite_test.go ├── github-url │ ├── repository.go │ └── repository_test.go ├── output │ └── output.go ├── scorecard │ ├── api │ │ ├── client.go │ │ ├── client_test.go │ │ └── options.go │ └── client.go ├── tally │ └── run.go └── types │ ├── package.go │ ├── package_repositories.go │ ├── report.go │ ├── repository.go │ └── result.go └── main.go /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: weekly 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "v*.*.*" 6 | jobs: 7 | goreleaser: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | id-token: write 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Unshallow 17 | run: git fetch --prune --unshallow 18 | 19 | - name: Install cosign 20 | uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 21 | 22 | - name: Install Go 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: 1.20.x 26 | cache: true 27 | 28 | - name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v4 30 | with: 31 | version: latest 32 | args: release --rm-dist 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | ko: 37 | runs-on: ubuntu-latest 38 | permissions: 39 | id-token: write 40 | packages: write 41 | contents: read 42 | env: 43 | KO_DOCKER_REPO: ghcr.io/${{ github.repository }} 44 | COSIGN_EXPERIMENTAL: "true" 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v3 48 | 49 | - name: Install Go 50 | uses: actions/setup-go@v4 51 | with: 52 | go-version: 1.20.x 53 | cache: true 54 | 55 | - name: Setup ko 56 | uses: imjasonh/setup-ko@ace48d793556083a76f1e3e6068850c1f4a369aa 57 | 58 | - name: Install cosign 59 | uses: sigstore/cosign-installer@6e04d228eb30da1757ee4e1dd75a0ec73a653e06 60 | 61 | - name: Login to ghcr.io 62 | uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc 63 | with: 64 | registry: ghcr.io 65 | username: ${{ github.repository_owner }} 66 | password: ${{ github.token }} 67 | 68 | - name: Build 69 | run: | 70 | ko build \ 71 | --bare \ 72 | -t latest \ 73 | -t ${{ github.ref_name }} \ 74 | --platform=linux/amd64,linux/arm64,linux/arm \ 75 | --sbom=cyclonedx \ 76 | --image-refs image-refs.txt 77 | cosign sign --yes $(cat image-refs.txt) 78 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | workflow_dispatch: {} 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Install Go 15 | uses: actions/setup-go@v4 16 | with: 17 | go-version: 1.20.x 18 | cache: true 19 | 20 | - name: Download 21 | run: go mod download 22 | 23 | - name: Vet 24 | run: go vet 25 | 26 | - name: Test 27 | run: go test ./... 28 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | - COSIGN_EXPERIMENTAL=true 3 | builds: 4 | - id: linux 5 | binary: tally 6 | env: 7 | - CGO_ENABLED=0 8 | goos: 9 | - linux 10 | goarch: 11 | - amd64 12 | - arm 13 | - arm64 14 | flags: 15 | - -v 16 | - id: darwin 17 | binary: tally 18 | env: 19 | - CGO_ENABLED=0 20 | goos: 21 | - darwin 22 | goarch: 23 | - amd64 24 | - arm64 25 | flags: 26 | - -v 27 | - id: windows 28 | binary: tally 29 | env: 30 | - CGO_ENABLED=0 31 | goos: 32 | - windows 33 | goarch: 34 | - amd64 35 | - arm64 36 | flags: 37 | - -v 38 | signs: 39 | - id: cosign 40 | cmd: cosign 41 | certificate: "${artifact}.crt" 42 | args: 43 | - "sign-blob" 44 | - "--yes" 45 | - "--output-signature" 46 | - "${signature}" 47 | - "--output-certificate" 48 | - "${certificate}" 49 | - "${artifact}" 50 | artifacts: all 51 | release: 52 | github: 53 | owner: jetstack 54 | name: tally 55 | -------------------------------------------------------------------------------- /.ko.yaml: -------------------------------------------------------------------------------- 1 | defaultBaseImage: gcr.io/distroless/base:nonroot 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tally 2 | 3 | Finds [OpenSSF Scorecard](https://github.com/ossf/scorecard) scores for packages 4 | in a Software Bill of Materials. 5 | 6 | ⚠️ This tool is currently under active development. There will be breaking changes 7 | and how it works may change significantly as it matures. 8 | 9 | ## Usage 10 | 11 | ### Basic 12 | 13 | Generate an SBOM in CycloneDX JSON format and then scan it with `tally`. 14 | 15 | This uses the [public scorecard API](https://api.securityscorecards.dev/#/) to 16 | fetch the latest score for each repository. 17 | 18 | ``` 19 | $ syft prom/prometheus -o cyclonedx-json > bom.json 20 | $ tally bom.json 21 | REPOSITORY SCORE 22 | github.com/googleapis/google-cloud-go 9.3 23 | github.com/imdario/mergo 9.1 24 | github.com/googleapis/gax-go 8.9 25 | github.com/kubernetes/api 8.2 26 | github.com/azure/go-autorest 8.0 27 | github.com/googleapis/go-genproto 7.9 28 | ... 29 | ``` 30 | 31 | You could also pipe the BOM directly to `tally`: 32 | 33 | ``` 34 | $ syft prom/prometheus -o cyclonedx-json | tally - 35 | ``` 36 | 37 | ### Generate scores 38 | 39 | The public API may not have a score for every discovered repository but `tally` 40 | can generate these scores itself when the `-g/--generate` flag is 41 | set. 42 | 43 | Scores are generated from the `HEAD` of the repository. 44 | 45 | This requires that the `GITHUB_TOKEN` environment variable is set to a valid 46 | token. 47 | 48 | ``` 49 | $ export GITHUB_TOKEN= 50 | $ tally -g bom.json 51 | Generating score for 'github.com/foo/bar' [--------->..] 68/72 52 | ``` 53 | 54 | This may take a while, depending on the number of missing scores. 55 | 56 | If you'd like to generate all the scores yourself, you can disable fetching 57 | scores from the API with `--api=false`. 58 | 59 | ### Cache 60 | 61 | To speed up subsequent runs, `tally` will cache scorecard results to a local 62 | database. You can disable the cache with `--cache=false`. 63 | 64 | By default, `tally` will ignore results that were cached more than 7 days ago. 65 | This window can be changed with the `--cache-duration` flag: 66 | 67 | ``` 68 | tally --cache-duration=20m bom.json 69 | ``` 70 | 71 | The cache is stored in the user's home cache directory, which is commonly 72 | located in `~/.cache/tally/cache/`. This can be changed with the `--cache-dir` 73 | flag. 74 | 75 | ### Fail on low scores 76 | 77 | The return code will be set to 1 when a score is identified that is less than 78 | or equal to the value of `--fail-on`: 79 | 80 | ``` 81 | $ tally --fail-on 3.5 bom.json 82 | ... 83 | Error: found scores <= to 3.50 84 | exit status 1 85 | ``` 86 | 87 | This will not consider packages `tally` has not been able to retrieve a score 88 | for. 89 | 90 | ### Output formats 91 | 92 | The `-o/--output` flag can be used to modify the output format. 93 | 94 | By default, `tally` will output each unique repository and its score: 95 | 96 | ``` 97 | REPOSITORY SCORE 98 | github.com/googleapis/google-cloud-go 9.3 99 | ``` 100 | 101 | The `wide` output format will print additional package information: 102 | 103 | ``` 104 | TYPE PACKAGE REPOSITORY SCORE 105 | golang cloud.google.com/go/compute github.com/googleapis/google-cloud-go 9.3 106 | ``` 107 | 108 | The `json` output will print the full report in JSON format: 109 | 110 | ``` 111 | $ tally -o json bom.json | jq -r . 112 | { 113 | "results": [ 114 | { 115 | "repository": "github.com/googleapis/google-http-java-client", 116 | "packages" : [ 117 | { 118 | "type": "maven", 119 | "name": "com.google.http-client/google-http-client-jackson2" 120 | } 121 | ], 122 | "result": { 123 | "date": "2023-03-04", 124 | "repo": { 125 | "name": "github.com/googleapis/google-http-java-client", 126 | "commit": "4e889b702b8bbfb082b7a3234569dc173c1c286d" 127 | }, 128 | "scorecard": { 129 | "version": "v4.8.0", 130 | "commit": "c40859202d739b31fd060ac5b30d17326cd74275" 131 | }, 132 | "score": 7, 133 | "checks": [ 134 | ... 135 | ] 136 | } 137 | }, 138 | ... 139 | ] 140 | } 141 | ``` 142 | 143 | ### Print all 144 | 145 | Not all packages will have a Scorecard score. 146 | 147 | By default, `tally` will remove results without a score from the output when 148 | using `-o short` or `-o wide`. 149 | 150 | You can include all results, regardless of whether they have a score or not, by 151 | specifying the `-a/--all` flag. 152 | 153 | ### BOM formats 154 | 155 | Specify the format of the target SBOM with the `-f/--format` flag. 156 | 157 | The supported SBOM formats are: 158 | 159 | - `cyclonedx-json` 160 | - `cyclonedx-xml` 161 | - `syft-json` 162 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "github.com/jetstack/tally/internal/bom" 11 | "github.com/jetstack/tally/internal/cache" 12 | "github.com/jetstack/tally/internal/output" 13 | "github.com/jetstack/tally/internal/scorecard" 14 | scorecardapi "github.com/jetstack/tally/internal/scorecard/api" 15 | "github.com/jetstack/tally/internal/tally" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | type rootOptions struct { 20 | All bool 21 | API bool 22 | APITimeout time.Duration 23 | APIURL string 24 | Cache bool 25 | CacheDir string 26 | CacheDuration time.Duration 27 | FailOn float64Flag 28 | Format string 29 | GenerateScores bool 30 | Output string 31 | } 32 | 33 | var ro rootOptions 34 | 35 | var rootCmd = &cobra.Command{ 36 | Use: "tally", 37 | Short: "Finds OpenSSF Scorecard scores for packages in a Software Bill of Materials.", 38 | Long: `Finds OpenSSF Scorecard scores for packages in a Software Bill of Materials.`, 39 | Args: cobra.ExactArgs(1), 40 | RunE: func(cmd *cobra.Command, args []string) (err error) { 41 | ctx := context.Background() 42 | 43 | // Configure the output writer 44 | out, err := output.NewOutput( 45 | output.Format(ro.Output), 46 | output.WithAll(ro.All), 47 | ) 48 | if err != nil { 49 | return fmt.Errorf("creating output writer: %w", err) 50 | } 51 | 52 | // Get packages from the BOM 53 | var r io.ReadCloser 54 | if args[0] == "-" { 55 | r = os.Stdin 56 | } else { 57 | r, err = os.Open(args[0]) 58 | if err != nil { 59 | return err 60 | } 61 | defer r.Close() 62 | } 63 | pkgRepos, err := bom.PackageRepositoriesFromBOM(r, bom.Format(ro.Format)) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | var scorecardClients []scorecard.Client 69 | 70 | // Fetch scores from the API 71 | if ro.API { 72 | apiClient, err := scorecardapi.NewClient(ro.APIURL) 73 | if err != nil { 74 | return fmt.Errorf("configuring API client: %w", err) 75 | } 76 | scorecardClients = append(scorecardClients, apiClient) 77 | } 78 | 79 | // Generate scores with the scorecard client 80 | if ro.GenerateScores { 81 | sc, err := scorecard.NewScorecardClient() 82 | if err != nil { 83 | return fmt.Errorf("configuring scorecard client: %w", err) 84 | } 85 | scorecardClients = append(scorecardClients, sc) 86 | } 87 | 88 | // At least one scorecard client must be configured 89 | if len(scorecardClients) == 0 { 90 | fmt.Fprintf(os.Stderr, "Error: no scorecard clients configured. At least one of --api or --generate must be set.\n") 91 | os.Exit(1) 92 | } 93 | 94 | // Cache scorecard results locally to speed up subsequent runs 95 | if ro.Cache { 96 | dbCache, err := cache.NewSqliteCache(ro.CacheDir, cache.WithDuration(ro.CacheDuration)) 97 | if err != nil { 98 | return fmt.Errorf("creating cache: %w", err) 99 | } 100 | 101 | // Wrap our clients with the cache 102 | for i, client := range scorecardClients { 103 | scorecardClients[i] = cache.NewScorecardClient(dbCache, client) 104 | } 105 | } 106 | 107 | // Run tally 108 | report, err := tally.Run(ctx, os.Stderr, scorecardClients, pkgRepos...) 109 | if err != nil { 110 | return fmt.Errorf("getting results: %w", err) 111 | } 112 | 113 | // Write report to output 114 | if err := out.WriteReport(os.Stdout, *report); err != nil { 115 | os.Exit(1) 116 | } 117 | 118 | // Exit 1 if there is a score <= o.FailOn 119 | if ro.FailOn.Value != nil { 120 | for _, result := range report.Results { 121 | if result.Result == nil || result.Result.Score > *ro.FailOn.Value { 122 | continue 123 | } 124 | fmt.Fprintf(os.Stderr, "Error: found scores <= to %0.2f\n", *ro.FailOn.Value) 125 | os.Exit(1) 126 | } 127 | } 128 | 129 | return nil 130 | }, 131 | } 132 | 133 | func Execute() { 134 | err := rootCmd.Execute() 135 | if err != nil { 136 | os.Exit(1) 137 | } 138 | } 139 | 140 | func init() { 141 | rootCmd.Flags().StringVarP(&ro.Format, "format", "f", string(bom.FormatCycloneDXJSON), fmt.Sprintf("BOM format, options=%s", bom.Formats)) 142 | rootCmd.Flags().BoolVarP(&ro.All, "all", "a", false, "print all packages, even those without a scorecard score") 143 | rootCmd.Flags().BoolVar(&ro.API, "api", true, "fetch scores from the Scorecard API") 144 | rootCmd.Flags().DurationVar(&ro.APITimeout, "api-timeout", scorecardapi.DefaultTimeout, "timeout for requests to scorecard API") 145 | rootCmd.Flags().StringVar(&ro.APIURL, "api-url", scorecardapi.DefaultURL, "scorecard API URL") 146 | rootCmd.Flags().StringVarP(&ro.Output, "output", "o", "short", fmt.Sprintf("output format, options=%s", output.Formats)) 147 | rootCmd.Flags().BoolVarP(&ro.GenerateScores, "generate", "g", false, "generate scores for repositories that aren't in the database. The GITHUB_TOKEN environment variable must be set.") 148 | rootCmd.Flags().BoolVar(&ro.Cache, "cache", true, "cache scores locally") 149 | rootCmd.Flags().StringVar(&ro.CacheDir, "cache-dir", "", "directory to cache scores in, defaults to $HOME/.cache/tally/cache on most systems") 150 | rootCmd.Flags().DurationVar(&ro.CacheDuration, "cache-duration", 7*(24*time.Hour), "how long to cache scores for; defaults to 7 days") 151 | rootCmd.Flags().Var(&ro.FailOn, "fail-on", "fail if a package is found with a score <= to the given value") 152 | } 153 | -------------------------------------------------------------------------------- /cmd/values.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | ) 7 | 8 | type float64Flag struct { 9 | Value *float64 10 | } 11 | 12 | func (f *float64Flag) String() string { 13 | if f.Value == nil { 14 | return "" 15 | } 16 | 17 | return fmt.Sprintf("%0.1f", *f.Value) 18 | } 19 | 20 | func (f *float64Flag) Set(value string) error { 21 | v, err := strconv.ParseFloat(value, 64) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | f.Value = &v 27 | 28 | return nil 29 | } 30 | 31 | func (f *float64Flag) Type() string { 32 | return "float64" 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jetstack/tally 2 | 3 | go 1.20 4 | 5 | replace github.com/spdx/tools-golang => github.com/spdx/tools-golang v0.4.0 6 | 7 | require ( 8 | github.com/CycloneDX/cyclonedx-go v0.7.1 9 | github.com/anchore/syft v0.86.1 10 | github.com/cheggaaa/pb/v3 v3.1.4 11 | github.com/google/go-cmp v0.5.9 12 | github.com/ossf/scorecard-webapp v1.0.5 13 | github.com/ossf/scorecard/v4 v4.10.5 14 | github.com/package-url/packageurl-go v0.1.1 15 | github.com/sirupsen/logrus v1.9.3 16 | github.com/spf13/cobra v1.7.0 17 | golang.org/x/sync v0.3.0 18 | modernc.org/sqlite v1.25.0 19 | ) 20 | 21 | require ( 22 | cloud.google.com/go v0.110.2 // indirect 23 | cloud.google.com/go/compute v1.20.0 // indirect 24 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 25 | cloud.google.com/go/iam v1.1.0 // indirect 26 | cloud.google.com/go/storage v1.30.1 // indirect 27 | dario.cat/mergo v1.0.0 // indirect 28 | github.com/BurntSushi/toml v1.2.1 // indirect 29 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 30 | github.com/Microsoft/go-winio v0.6.1 // indirect 31 | github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect 32 | github.com/VividCortex/ewma v1.2.0 // indirect 33 | github.com/acobaugh/osrelease v0.1.0 // indirect 34 | github.com/acomagu/bufpipe v1.0.4 // indirect 35 | github.com/anchore/go-logger v0.0.0-20230531193951-db5ae83e7dbe // indirect 36 | github.com/anchore/packageurl-go v0.1.1-0.20230104203445-02e0a6721501 // indirect 37 | github.com/anchore/stereoscope v0.0.0-20230727211946-d1f3d766295e // indirect 38 | github.com/andybalholm/brotli v1.0.4 // indirect 39 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 40 | github.com/becheran/wildmatch-go v1.0.0 // indirect 41 | github.com/bmatcuk/doublestar/v4 v4.6.0 // indirect 42 | github.com/bombsimon/logrusr/v2 v2.0.1 // indirect 43 | github.com/bradleyfalzon/ghinstallation/v2 v2.1.0 // indirect 44 | github.com/caarlos0/env/v6 v6.10.0 // indirect 45 | github.com/cloudflare/circl v1.3.3 // indirect 46 | github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be // indirect 47 | github.com/containerd/containerd v1.7.0 // indirect 48 | github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect 49 | github.com/containerd/typeurl v1.0.2 // indirect 50 | github.com/docker/cli v23.0.5+incompatible // indirect 51 | github.com/docker/distribution v2.8.2+incompatible // indirect 52 | github.com/docker/docker v24.0.5+incompatible // indirect 53 | github.com/docker/docker-credential-helpers v0.7.0 // indirect 54 | github.com/docker/go-connections v0.4.0 // indirect 55 | github.com/docker/go-units v0.5.0 // indirect 56 | github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect 57 | github.com/dustin/go-humanize v1.0.1 // indirect 58 | github.com/emirpasic/gods v1.18.1 // indirect 59 | github.com/facebookincubator/nvdtools v0.1.5 // indirect 60 | github.com/fatih/color v1.15.0 // indirect 61 | github.com/gabriel-vasile/mimetype v1.4.0 // indirect 62 | github.com/github/go-spdx/v2 v2.1.2 // indirect 63 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 64 | github.com/go-git/go-billy/v5 v5.4.1 // indirect 65 | github.com/go-git/go-git/v5 v5.8.1 // indirect 66 | github.com/go-logr/logr v1.2.4 // indirect 67 | github.com/go-openapi/analysis v0.21.4 // indirect 68 | github.com/go-openapi/errors v0.20.4 // indirect 69 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 70 | github.com/go-openapi/jsonreference v0.20.2 // indirect 71 | github.com/go-openapi/loads v0.21.2 // indirect 72 | github.com/go-openapi/spec v0.20.9 // indirect 73 | github.com/go-openapi/strfmt v0.21.7 // indirect 74 | github.com/go-openapi/swag v0.22.4 // indirect 75 | github.com/go-openapi/validate v0.22.1 // indirect 76 | github.com/gogo/protobuf v1.3.2 // indirect 77 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 78 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 79 | github.com/golang/protobuf v1.5.3 // indirect 80 | github.com/golang/snappy v0.0.4 // indirect 81 | github.com/google/go-containerregistry v0.15.2 // indirect 82 | github.com/google/go-github/v38 v38.1.0 // indirect 83 | github.com/google/go-github/v45 v45.2.0 // indirect 84 | github.com/google/go-querystring v1.1.0 // indirect 85 | github.com/google/osv-scanner v1.2.1-0.20230302232134-592acbc2539b // indirect 86 | github.com/google/s2a-go v0.1.4 // indirect 87 | github.com/google/uuid v1.3.0 // indirect 88 | github.com/google/wire v0.5.0 // indirect 89 | github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect 90 | github.com/googleapis/gax-go/v2 v2.11.0 // indirect 91 | github.com/h2non/filetype v1.1.3 // indirect 92 | github.com/hashicorp/errwrap v1.1.0 // indirect 93 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 94 | github.com/hashicorp/go-multierror v1.1.1 // indirect 95 | github.com/hashicorp/go-retryablehttp v0.7.2 // indirect 96 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 97 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 98 | github.com/jedib0t/go-pretty/v6 v6.4.4 // indirect 99 | github.com/jinzhu/copier v0.3.5 // indirect 100 | github.com/josharian/intern v1.0.0 // indirect 101 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 102 | github.com/kevinburke/ssh_config v1.2.0 // indirect 103 | github.com/klauspost/compress v1.16.5 // indirect 104 | github.com/klauspost/pgzip v1.2.5 // indirect 105 | github.com/mailru/easyjson v0.7.7 // indirect 106 | github.com/mattn/go-colorable v0.1.13 // indirect 107 | github.com/mattn/go-isatty v0.0.19 // indirect 108 | github.com/mattn/go-runewidth v0.0.14 // indirect 109 | github.com/mholt/archiver/v3 v3.5.1 // indirect 110 | github.com/mitchellh/go-homedir v1.1.0 // indirect 111 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 112 | github.com/mitchellh/mapstructure v1.5.0 // indirect 113 | github.com/moby/buildkit v0.11.4 // indirect 114 | github.com/nwaples/rardecode v1.1.0 // indirect 115 | github.com/oklog/ulid v1.3.1 // indirect 116 | github.com/olekukonko/tablewriter v0.0.5 // indirect 117 | github.com/opencontainers/go-digest v1.0.0 // indirect 118 | github.com/opencontainers/image-spec v1.1.0-rc3 // indirect 119 | github.com/pelletier/go-toml v1.9.5 // indirect 120 | github.com/pierrec/lz4/v4 v4.1.15 // indirect 121 | github.com/pjbgf/sha1cd v0.3.0 // indirect 122 | github.com/pkg/errors v0.9.1 // indirect 123 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 124 | github.com/rhysd/actionlint v1.6.25 // indirect 125 | github.com/rivo/uniseg v0.4.4 // indirect 126 | github.com/robfig/cron v1.2.0 // indirect 127 | github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect 128 | github.com/sergi/go-diff v1.3.1 // indirect 129 | github.com/shurcooL/githubv4 v0.0.0-20201206200315-234843c633fa // indirect 130 | github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect 131 | github.com/skeema/knownhosts v1.2.0 // indirect 132 | github.com/spdx/gordf v0.0.0-20221230105357-b735bd5aac89 // indirect 133 | github.com/spdx/tools-golang v0.5.3 // indirect 134 | github.com/spf13/afero v1.9.5 // indirect 135 | github.com/spf13/pflag v1.0.5 // indirect 136 | github.com/sylabs/sif/v2 v2.11.5 // indirect 137 | github.com/sylabs/squashfs v0.6.1 // indirect 138 | github.com/therootcompany/xz v1.0.1 // indirect 139 | github.com/ulikunitz/xz v0.5.10 // indirect 140 | github.com/vbatts/tar-split v0.11.3 // indirect 141 | github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect 142 | github.com/wagoodman/go-progress v0.0.0-20230301185719-21920a456ad5 // indirect 143 | github.com/xanzy/go-gitlab v0.78.0 // indirect 144 | github.com/xanzy/ssh-agent v0.3.3 // indirect 145 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 146 | go.mongodb.org/mongo-driver v1.11.3 // indirect 147 | go.opencensus.io v0.24.0 // indirect 148 | gocloud.dev v0.30.0 // indirect 149 | golang.org/x/crypto v0.11.0 // indirect 150 | golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect 151 | golang.org/x/mod v0.12.0 // indirect 152 | golang.org/x/net v0.12.0 // indirect 153 | golang.org/x/oauth2 v0.9.0 // indirect 154 | golang.org/x/sys v0.10.0 // indirect 155 | golang.org/x/term v0.10.0 // indirect 156 | golang.org/x/text v0.11.0 // indirect 157 | golang.org/x/time v0.3.0 // indirect 158 | golang.org/x/tools v0.9.3 // indirect 159 | golang.org/x/vuln v0.0.0-20230118164824-4ec8867cc0e6 // indirect 160 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 161 | google.golang.org/api v0.128.0 // indirect 162 | google.golang.org/appengine v1.6.7 // indirect 163 | google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect 164 | google.golang.org/genproto/googleapis/api v0.0.0-20230530153820-e85fd2cbaebc // indirect 165 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect 166 | google.golang.org/grpc v1.56.0 // indirect 167 | google.golang.org/protobuf v1.30.0 // indirect 168 | gopkg.in/warnings.v0 v0.1.2 // indirect 169 | gopkg.in/yaml.v2 v2.4.0 // indirect 170 | gopkg.in/yaml.v3 v3.0.1 // indirect 171 | lukechampine.com/uint128 v1.2.0 // indirect 172 | modernc.org/cc/v3 v3.40.0 // indirect 173 | modernc.org/ccgo/v3 v3.16.13 // indirect 174 | modernc.org/libc v1.24.1 // indirect 175 | modernc.org/mathutil v1.5.0 // indirect 176 | modernc.org/memory v1.6.0 // indirect 177 | modernc.org/opt v0.1.3 // indirect 178 | modernc.org/strutil v1.1.3 // indirect 179 | modernc.org/token v1.0.1 // indirect 180 | mvdan.cc/sh/v3 v3.6.0 // indirect 181 | sigs.k8s.io/release-utils v0.6.0 // indirect 182 | ) 183 | -------------------------------------------------------------------------------- /internal/bom/bom.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/CycloneDX/cyclonedx-go" 8 | "github.com/jetstack/tally/internal/types" 9 | ) 10 | 11 | // Format is a supported SBOM format 12 | type Format string 13 | 14 | const ( 15 | FormatCycloneDXJSON Format = "cyclonedx-json" 16 | FormatCycloneDXXML Format = "cyclonedx-xml" 17 | FormatSyftJSON Format = "syft-json" 18 | ) 19 | 20 | // Formats are all the supported SBOM formats 21 | var Formats = []Format{ 22 | FormatCycloneDXJSON, 23 | FormatCycloneDXXML, 24 | FormatSyftJSON, 25 | } 26 | 27 | // PackageRepositoriesFromBOM discovers packages and their associated 28 | // repositories in an SBOM 29 | func PackageRepositoriesFromBOM(r io.Reader, format Format) ([]*types.PackageRepositories, error) { 30 | switch format { 31 | case FormatCycloneDXJSON: 32 | bom, err := ParseCycloneDXBOM(r, cyclonedx.BOMFileFormatJSON) 33 | if err != nil { 34 | return nil, fmt.Errorf("parsing BOM in cyclonedx-json format: %w", err) 35 | } 36 | return PackageRepositoriesFromCycloneDXBOM(bom) 37 | case FormatCycloneDXXML: 38 | bom, err := ParseCycloneDXBOM(r, cyclonedx.BOMFileFormatXML) 39 | if err != nil { 40 | return nil, fmt.Errorf("parsing BOM in cyclonedx-xml format: %w", err) 41 | } 42 | return PackageRepositoriesFromCycloneDXBOM(bom) 43 | case FormatSyftJSON: 44 | bom, err := ParseSyftBOM(r) 45 | if err != nil { 46 | return nil, fmt.Errorf("parsing BOM in syft-json format: %w", err) 47 | } 48 | return PackageRepositoriesFromSyftBOM(bom) 49 | default: 50 | return nil, fmt.Errorf("unsupported format: %s", format) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/bom/cyclonedx.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/CycloneDX/cyclonedx-go" 8 | github_url "github.com/jetstack/tally/internal/github-url" 9 | "github.com/jetstack/tally/internal/types" 10 | ) 11 | 12 | // ParseCycloneDXBOM parses a cyclonedx BOM in the specified format 13 | func ParseCycloneDXBOM(r io.Reader, format cyclonedx.BOMFileFormat) (*cyclonedx.BOM, error) { 14 | bom := &cyclonedx.BOM{} 15 | if err := cyclonedx.NewBOMDecoder(r, format).Decode(bom); err != nil { 16 | return nil, fmt.Errorf("decoding cyclonedx BOM: %w", err) 17 | } 18 | 19 | return bom, nil 20 | } 21 | 22 | // PackageRepositoriesFromCycloneDXBOM extracts packages from a cyclonedx BOM 23 | func PackageRepositoriesFromCycloneDXBOM(bom *cyclonedx.BOM) ([]*types.PackageRepositories, error) { 24 | var pkgRepos []*types.PackageRepositories 25 | if err := foreachComponentIn( 26 | bom, 27 | func(component cyclonedx.Component) error { 28 | pkgRepo, err := packageRepositoriesFromCycloneDXComponent(component) 29 | if err != nil { 30 | return err 31 | } 32 | if pkgRepo == nil { 33 | return nil 34 | } 35 | 36 | pkgRepos = appendPackageRepositories(pkgRepos, pkgRepo) 37 | 38 | return nil 39 | 40 | }, 41 | ); err != nil { 42 | return nil, fmt.Errorf("finding packages in BOM: %w", err) 43 | } 44 | 45 | return pkgRepos, nil 46 | } 47 | 48 | func packageRepositoriesFromCycloneDXComponent(component cyclonedx.Component) (*types.PackageRepositories, error) { 49 | if component.PackageURL == "" { 50 | return nil, nil 51 | } 52 | pkgRepo, err := packageRepositoriesFromPurl(component.PackageURL) 53 | if err != nil { 54 | return nil, err 55 | } 56 | if component.ExternalReferences == nil { 57 | return pkgRepo, nil 58 | } 59 | for _, ref := range *component.ExternalReferences { 60 | switch ref.Type { 61 | case cyclonedx.ERTypeVCS, cyclonedx.ERTypeDistribution, cyclonedx.ERTypeWebsite: 62 | repo := github_url.ToRepository(ref.URL) 63 | if repo == nil { 64 | continue 65 | } 66 | pkgRepo.AddRepositories(*repo) 67 | } 68 | } 69 | 70 | return pkgRepo, nil 71 | } 72 | 73 | func foreachComponentIn(bom *cyclonedx.BOM, fn func(component cyclonedx.Component) error) error { 74 | var components []cyclonedx.Component 75 | if bom.Metadata != nil && bom.Metadata.Component != nil { 76 | components = append(components, *bom.Metadata.Component) 77 | } 78 | if bom.Components != nil { 79 | components = append(components, *bom.Components...) 80 | } 81 | return walkCycloneDXComponents(components, fn) 82 | } 83 | 84 | func walkCycloneDXComponents(components []cyclonedx.Component, fn func(cyclonedx.Component) error) error { 85 | for _, component := range components { 86 | if err := fn(component); err != nil { 87 | return err 88 | } 89 | if component.Components == nil { 90 | continue 91 | } 92 | if err := walkCycloneDXComponents(*component.Components, fn); err != nil { 93 | return err 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /internal/bom/cyclonedx_test.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "encoding/xml" 5 | "os" 6 | "testing" 7 | 8 | "github.com/CycloneDX/cyclonedx-go" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/jetstack/tally/internal/types" 11 | ) 12 | 13 | func TestPackagesFromCycloneDXBOM(t *testing.T) { 14 | testCases := map[string]struct { 15 | path string 16 | format cyclonedx.BOMFileFormat 17 | wantBOM *cyclonedx.BOM 18 | wantErr bool 19 | }{ 20 | "json is parsed successfully": { 21 | path: "testdata/cdx.json", 22 | format: cyclonedx.BOMFileFormatJSON, 23 | wantBOM: &cyclonedx.BOM{ 24 | BOMFormat: "CycloneDX", 25 | SpecVersion: cyclonedx.SpecVersion1_4, 26 | SerialNumber: "urn:uuid:5e0841b1-88e1-4dd8-b706-77457fb3e779", 27 | Version: 1, 28 | Metadata: &cyclonedx.Metadata{ 29 | Component: &cyclonedx.Component{ 30 | BOMRef: "1234567", 31 | Type: "application", 32 | Name: "foo/bar", 33 | Version: "v0.2.5", 34 | PackageURL: "pkg:golang/foo/bar@v0.2.5", 35 | }, 36 | }, 37 | Components: &[]cyclonedx.Component{ 38 | { 39 | BOMRef: "0", 40 | Type: "library", 41 | Name: "HdrHistogram", 42 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 43 | }, 44 | { 45 | BOMRef: "1", 46 | Type: "library", 47 | Name: "adduser", 48 | PackageURL: "pkg:deb/debian/adduser@3.118?arch=all&distro=debian-11", 49 | }, 50 | }, 51 | }, 52 | }, 53 | "error is returned when parsing invalid json": { 54 | path: "testdata/cdx.json.invalid", 55 | format: cyclonedx.BOMFileFormatJSON, 56 | wantErr: true, 57 | }, 58 | "xml": { 59 | path: "testdata/cdx.xml", 60 | format: cyclonedx.BOMFileFormatXML, 61 | wantBOM: &cyclonedx.BOM{ 62 | SpecVersion: cyclonedx.SpecVersion1_3, 63 | SerialNumber: "urn:uuid:5e0841b1-88e1-4dd8-b706-77457fb3e779", 64 | Version: 1, 65 | XMLName: xml.Name{ 66 | Space: "http://cyclonedx.org/schema/bom/1.3", 67 | Local: "bom", 68 | }, 69 | XMLNS: "http://cyclonedx.org/schema/bom/1.3", 70 | Metadata: &cyclonedx.Metadata{ 71 | Component: &cyclonedx.Component{ 72 | BOMRef: "1234567", 73 | Type: "application", 74 | Name: "foo/bar", 75 | Version: "v0.2.5", 76 | PackageURL: "pkg:golang/foo/bar@v0.2.5", 77 | }, 78 | }, 79 | Components: &[]cyclonedx.Component{ 80 | { 81 | BOMRef: "0", 82 | Type: "library", 83 | Name: "HdrHistogram", 84 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 85 | }, 86 | { 87 | BOMRef: "1", 88 | Type: "library", 89 | Name: "adduser", 90 | PackageURL: "pkg:deb/debian/adduser@3.118?arch=all&distro=debian-11", 91 | }, 92 | }, 93 | }, 94 | }, 95 | "error is returned when parsing invalid xml": { 96 | path: "testdata/cdx.xml.invalid", 97 | format: cyclonedx.BOMFileFormatXML, 98 | wantErr: true, 99 | }, 100 | } 101 | for n, tc := range testCases { 102 | t.Run(n, func(t *testing.T) { 103 | r, err := os.Open(tc.path) 104 | if err != nil { 105 | t.Fatalf("unexpected error opening file: %s", err) 106 | } 107 | defer r.Close() 108 | 109 | gotBOM, err := ParseCycloneDXBOM(r, tc.format) 110 | if err != nil && !tc.wantErr { 111 | t.Fatalf("unexpected error parsing BOM: %s", err) 112 | } 113 | if err == nil && tc.wantErr { 114 | t.Fatalf("expected parsing BOM but got nil") 115 | } 116 | 117 | if tc.wantErr { 118 | return 119 | } 120 | 121 | if diff := cmp.Diff(tc.wantBOM, gotBOM); diff != "" { 122 | t.Errorf("unexpected BOM:\n%s", diff) 123 | } 124 | }) 125 | } 126 | } 127 | 128 | func TestPackageRepositoriesFromCycloneDXBOM(t *testing.T) { 129 | testCases := map[string]struct { 130 | bom *cyclonedx.BOM 131 | wantPackages []*types.PackageRepositories 132 | }{ 133 | "an error should not be produced for an empty BOM": { 134 | bom: &cyclonedx.BOM{}, 135 | }, 136 | "an error should not be produced when metadata.component is nil": { 137 | bom: &cyclonedx.BOM{ 138 | Metadata: &cyclonedx.Metadata{}, 139 | }, 140 | }, 141 | "packages should be discovered in metadata.component": { 142 | bom: &cyclonedx.BOM{ 143 | Metadata: &cyclonedx.Metadata{ 144 | Component: &cyclonedx.Component{ 145 | PackageURL: "pkg:golang/foo/bar@v0.2.5", 146 | }, 147 | }, 148 | }, 149 | wantPackages: []*types.PackageRepositories{ 150 | { 151 | Package: types.Package{ 152 | Type: "golang", 153 | Name: "foo/bar", 154 | }, 155 | }, 156 | }, 157 | }, 158 | "packages should be discovered in metadata.component AND components": { 159 | bom: &cyclonedx.BOM{ 160 | Metadata: &cyclonedx.Metadata{ 161 | Component: &cyclonedx.Component{ 162 | PackageURL: "pkg:golang/foo/bar@v0.2.5", 163 | }}, 164 | Components: &[]cyclonedx.Component{ 165 | { 166 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 167 | }, 168 | { 169 | PackageURL: "pkg:deb/debian/adduser@3.118?arch=all\u0026distro=debian-11", 170 | }, 171 | }, 172 | }, 173 | wantPackages: []*types.PackageRepositories{ 174 | { 175 | Package: types.Package{ 176 | Type: "golang", 177 | Name: "foo/bar", 178 | }, 179 | }, 180 | { 181 | Package: types.Package{ 182 | Type: "maven", 183 | Name: "org.hdrhistogram/HdrHistogram", 184 | }, 185 | }, 186 | { 187 | Package: types.Package{ 188 | Type: "deb", 189 | Name: "debian/adduser", 190 | }, 191 | }, 192 | }, 193 | }, 194 | "packages should be discovered in nested components in metadata.component AND components": { 195 | bom: &cyclonedx.BOM{ 196 | Metadata: &cyclonedx.Metadata{ 197 | Component: &cyclonedx.Component{ 198 | PackageURL: "pkg:golang/foo/bar@v0.2.5", 199 | Components: &[]cyclonedx.Component{ 200 | { 201 | PackageURL: "pkg:golang/sigs.k8s.io/release-utils@v0.7.3", 202 | }, 203 | }, 204 | }}, 205 | Components: &[]cyclonedx.Component{ 206 | { 207 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 208 | Components: &[]cyclonedx.Component{ 209 | { 210 | Components: &[]cyclonedx.Component{ 211 | { 212 | PackageURL: "pkg:maven/com.github.package-url/packageurl-java@1.4.1", 213 | }, 214 | }, 215 | }, 216 | }, 217 | }, 218 | }, 219 | }, 220 | wantPackages: []*types.PackageRepositories{ 221 | { 222 | Package: types.Package{ 223 | Type: "golang", 224 | Name: "foo/bar", 225 | }, 226 | }, 227 | { 228 | Package: types.Package{ 229 | Type: "golang", 230 | Name: "sigs.k8s.io/release-utils", 231 | }, 232 | }, 233 | { 234 | Package: types.Package{ 235 | Type: "maven", 236 | Name: "org.hdrhistogram/HdrHistogram", 237 | }, 238 | }, 239 | { 240 | Package: types.Package{ 241 | Type: "maven", 242 | Name: "com.github.package-url/packageurl-java", 243 | }, 244 | }, 245 | }, 246 | }, 247 | "components without a PackageURL should be ignored": { 248 | bom: &cyclonedx.BOM{ 249 | Components: &[]cyclonedx.Component{ 250 | { 251 | Name: "foo/bar", 252 | }, 253 | }, 254 | }, 255 | }, 256 | "duplicate packages should be ignored": { 257 | bom: &cyclonedx.BOM{ 258 | Components: &[]cyclonedx.Component{ 259 | { 260 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.8", 261 | }, 262 | { 263 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 264 | }, 265 | { 266 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 267 | }, 268 | }, 269 | }, 270 | wantPackages: []*types.PackageRepositories{ 271 | { 272 | Package: types.Package{ 273 | Type: "maven", 274 | Name: "org.hdrhistogram/HdrHistogram", 275 | }, 276 | }, 277 | }, 278 | }, 279 | "all supported types should be discovered": { 280 | bom: &cyclonedx.BOM{ 281 | Components: &[]cyclonedx.Component{ 282 | { 283 | PackageURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 284 | }, 285 | { 286 | PackageURL: "pkg:golang/sigs.k8s.io/release-utils@v0.7.3", 287 | }, 288 | { 289 | PackageURL: "pkg:npm/zwitch@2.0.2", 290 | }, 291 | { 292 | PackageURL: "pkg:cargo/getrandom@0.2.7", 293 | }, 294 | { 295 | PackageURL: "pkg:pypi/zope.interface@5.4.0", 296 | }, 297 | }, 298 | }, 299 | wantPackages: []*types.PackageRepositories{ 300 | { 301 | Package: types.Package{ 302 | Type: "maven", 303 | Name: "org.hdrhistogram/HdrHistogram", 304 | }, 305 | }, 306 | { 307 | Package: types.Package{ 308 | Type: "golang", 309 | Name: "sigs.k8s.io/release-utils", 310 | }, 311 | }, 312 | { 313 | Package: types.Package{ 314 | Type: "npm", 315 | Name: "zwitch", 316 | }, 317 | }, 318 | { 319 | Package: types.Package{ 320 | Type: "cargo", 321 | Name: "getrandom", 322 | }, 323 | }, 324 | { 325 | Package: types.Package{ 326 | Type: "pypi", 327 | Name: "zope.interface", 328 | }, 329 | }, 330 | }, 331 | }, 332 | "repositories can be extracted from metadata.components": { 333 | bom: &cyclonedx.BOM{ 334 | Components: &[]cyclonedx.Component{ 335 | { 336 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 337 | ExternalReferences: &[]cyclonedx.ExternalReference{ 338 | { 339 | Type: cyclonedx.ERTypeVCS, 340 | URL: "https://github.com/bar/foo", 341 | }, 342 | }, 343 | }, 344 | }, 345 | }, 346 | wantPackages: []*types.PackageRepositories{ 347 | { 348 | Package: types.Package{ 349 | Type: "golang", 350 | Name: "foo/bar", 351 | }, 352 | Repositories: []types.Repository{ 353 | { 354 | Name: "github.com/bar/foo", 355 | }, 356 | }, 357 | }, 358 | }, 359 | }, 360 | "repositories can be extracted from components": { 361 | bom: &cyclonedx.BOM{ 362 | Components: &[]cyclonedx.Component{ 363 | { 364 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 365 | ExternalReferences: &[]cyclonedx.ExternalReference{ 366 | { 367 | Type: cyclonedx.ERTypeVCS, 368 | URL: "https://github.com/bar/foo", 369 | }, 370 | }, 371 | }, 372 | }, 373 | }, 374 | wantPackages: []*types.PackageRepositories{ 375 | { 376 | Package: types.Package{ 377 | Type: "golang", 378 | Name: "foo/bar", 379 | }, 380 | Repositories: []types.Repository{ 381 | { 382 | Name: "github.com/bar/foo", 383 | }, 384 | }, 385 | }, 386 | }, 387 | }, 388 | "repositories can be extracted from nested components": { 389 | bom: &cyclonedx.BOM{ 390 | Components: &[]cyclonedx.Component{ 391 | { 392 | PackageURL: "pkg:golang/bar/foo@v0.1.1", 393 | Components: &[]cyclonedx.Component{ 394 | { 395 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 396 | ExternalReferences: &[]cyclonedx.ExternalReference{ 397 | { 398 | Type: cyclonedx.ERTypeVCS, 399 | URL: "https://github.com/bar/foo", 400 | }, 401 | }, 402 | }, 403 | }, 404 | }, 405 | }, 406 | }, 407 | wantPackages: []*types.PackageRepositories{ 408 | { 409 | Package: types.Package{ 410 | Type: "golang", 411 | Name: "bar/foo", 412 | }, 413 | }, 414 | { 415 | Package: types.Package{ 416 | Type: "golang", 417 | Name: "foo/bar", 418 | }, 419 | Repositories: []types.Repository{ 420 | { 421 | Name: "github.com/bar/foo", 422 | }, 423 | }, 424 | }, 425 | }, 426 | }, 427 | "multiple repositories can be extracted from the same component": { 428 | bom: &cyclonedx.BOM{ 429 | Components: &[]cyclonedx.Component{ 430 | { 431 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 432 | ExternalReferences: &[]cyclonedx.ExternalReference{ 433 | { 434 | Type: cyclonedx.ERTypeVCS, 435 | URL: "https://github.com/bar/foo", 436 | }, 437 | { 438 | Type: cyclonedx.ERTypeVCS, 439 | URL: "git@github.com:baz/bar", 440 | }, 441 | }, 442 | }, 443 | }, 444 | }, 445 | wantPackages: []*types.PackageRepositories{ 446 | { 447 | Package: types.Package{ 448 | Type: "golang", 449 | Name: "foo/bar", 450 | }, 451 | Repositories: []types.Repository{ 452 | { 453 | 454 | Name: "github.com/bar/foo", 455 | }, 456 | { 457 | Name: "github.com/baz/bar", 458 | }, 459 | }, 460 | }, 461 | }, 462 | }, 463 | "multiple repositories can be extracted from different components": { 464 | bom: &cyclonedx.BOM{ 465 | Metadata: &cyclonedx.Metadata{ 466 | Component: &cyclonedx.Component{ 467 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 468 | ExternalReferences: &[]cyclonedx.ExternalReference{ 469 | { 470 | Type: cyclonedx.ERTypeVCS, 471 | URL: "https://github.com/bar/foo", 472 | }, 473 | }, 474 | }, 475 | }, 476 | Components: &[]cyclonedx.Component{ 477 | { 478 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 479 | ExternalReferences: &[]cyclonedx.ExternalReference{ 480 | { 481 | Type: cyclonedx.ERTypeVCS, 482 | URL: "git@github.com:baz/bar", 483 | }, 484 | }, 485 | }, 486 | }, 487 | }, 488 | wantPackages: []*types.PackageRepositories{ 489 | { 490 | Package: types.Package{ 491 | Type: "golang", 492 | Name: "foo/bar", 493 | }, 494 | Repositories: []types.Repository{ 495 | { 496 | Name: "github.com/bar/foo", 497 | }, 498 | { 499 | Name: "github.com/baz/bar", 500 | }, 501 | }, 502 | }, 503 | }, 504 | }, 505 | "repositories are deduplicated": { 506 | bom: &cyclonedx.BOM{ 507 | Metadata: &cyclonedx.Metadata{ 508 | Component: &cyclonedx.Component{ 509 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 510 | ExternalReferences: &[]cyclonedx.ExternalReference{ 511 | { 512 | Type: cyclonedx.ERTypeVCS, 513 | URL: "https://github.com/bar/foo", 514 | }, 515 | }, 516 | }, 517 | }, 518 | Components: &[]cyclonedx.Component{ 519 | { 520 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 521 | ExternalReferences: &[]cyclonedx.ExternalReference{ 522 | { 523 | Type: cyclonedx.ERTypeVCS, 524 | URL: "https://github.com/bar/foo", 525 | }, 526 | }, 527 | }, 528 | }, 529 | }, 530 | wantPackages: []*types.PackageRepositories{ 531 | { 532 | Package: types.Package{ 533 | Type: "golang", 534 | Name: "foo/bar", 535 | }, 536 | Repositories: []types.Repository{ 537 | { 538 | Name: "github.com/bar/foo", 539 | }, 540 | }, 541 | }, 542 | }, 543 | }, 544 | "repositories are extracted and deduplicated from all supported types": { 545 | bom: &cyclonedx.BOM{ 546 | Components: &[]cyclonedx.Component{ 547 | { 548 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 549 | ExternalReferences: &[]cyclonedx.ExternalReference{ 550 | { 551 | Type: cyclonedx.ERTypeVCS, 552 | URL: "https://github.com/bar/foo", 553 | }, 554 | { 555 | Type: cyclonedx.ERTypeDistribution, 556 | URL: "https://github.com/bar/foo.git", 557 | }, 558 | { 559 | Type: cyclonedx.ERTypeWebsite, 560 | URL: "http://github.com/bar/foo.git", 561 | }, 562 | }, 563 | }, 564 | { 565 | PackageURL: "pkg:golang/foo/bar@v0.2.2", 566 | ExternalReferences: &[]cyclonedx.ExternalReference{ 567 | { 568 | Type: cyclonedx.ERTypeWebsite, 569 | URL: "https://github.com/bar/foo.git", 570 | }, 571 | }, 572 | }, 573 | { 574 | PackageURL: "pkg:golang/foo/bar@v0.2.2", 575 | ExternalReferences: &[]cyclonedx.ExternalReference{ 576 | { 577 | Type: cyclonedx.ERTypeDistribution, 578 | URL: "https://github.com/foo/baz.git", 579 | }, 580 | }, 581 | }, 582 | { 583 | PackageURL: "pkg:golang/foo/bar@v0.1.1", 584 | ExternalReferences: &[]cyclonedx.ExternalReference{ 585 | { 586 | Type: cyclonedx.ERTypeWebsite, 587 | URL: "https://github.com/foo/bar", 588 | }, 589 | }, 590 | }, 591 | }, 592 | }, 593 | wantPackages: []*types.PackageRepositories{ 594 | { 595 | Package: types.Package{ 596 | Type: "golang", 597 | Name: "foo/bar", 598 | }, 599 | Repositories: []types.Repository{ 600 | { 601 | Name: "github.com/bar/foo", 602 | }, 603 | { 604 | Name: "github.com/foo/baz", 605 | }, 606 | { 607 | Name: "github.com/foo/bar", 608 | }, 609 | }, 610 | }, 611 | }, 612 | }, 613 | "repository is extracted from the package url": { 614 | bom: &cyclonedx.BOM{ 615 | Components: &[]cyclonedx.Component{ 616 | { 617 | PackageURL: "pkg:pypi/foo.bar@5.4.0?vcs_url=git+git+ssh://git@github.com:foo/bar.git#v5.4.0", 618 | }, 619 | }, 620 | }, 621 | wantPackages: []*types.PackageRepositories{ 622 | { 623 | Package: types.Package{ 624 | Type: "pypi", 625 | Name: "foo.bar", 626 | }, 627 | Repositories: []types.Repository{ 628 | { 629 | Name: "github.com/foo/bar", 630 | }, 631 | }, 632 | }, 633 | }, 634 | }, 635 | } 636 | for n, tc := range testCases { 637 | t.Run(n, func(t *testing.T) { 638 | gotPackages, err := PackageRepositoriesFromCycloneDXBOM(tc.bom) 639 | if err != nil { 640 | t.Fatalf("unexpected error getting packages from bom: %s", err) 641 | } 642 | if diff := cmp.Diff(tc.wantPackages, gotPackages); diff != "" { 643 | t.Errorf("unexpected packages:\n%s", diff) 644 | } 645 | }) 646 | } 647 | } 648 | -------------------------------------------------------------------------------- /internal/bom/purl.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "strings" 5 | 6 | github_url "github.com/jetstack/tally/internal/github-url" 7 | "github.com/jetstack/tally/internal/types" 8 | "github.com/package-url/packageurl-go" 9 | ) 10 | 11 | func packageRepositoriesFromPurl(purl string) (*types.PackageRepositories, error) { 12 | p, err := packageurl.FromString(purl) 13 | if err != nil { 14 | return nil, err 15 | } 16 | pkgRepo := &types.PackageRepositories{ 17 | Package: types.Package{ 18 | Type: p.Type, 19 | Name: p.Name, 20 | }, 21 | } 22 | if p.Namespace != "" { 23 | pkgRepo.Name = p.Namespace + "/" + p.Name 24 | } 25 | 26 | repo := github_url.ToRepository(p.Qualifiers.Map()["vcs_url"]) 27 | if repo != nil { 28 | pkgRepo.AddRepositories(*repo) 29 | } 30 | 31 | switch pkgRepo.Type { 32 | case "golang": 33 | if !strings.HasPrefix(pkgRepo.Name, "github.com/") { 34 | return pkgRepo, nil 35 | } 36 | parts := strings.Split(pkgRepo.Name, "/") 37 | if len(parts) < 3 { 38 | return pkgRepo, nil 39 | } 40 | 41 | pkgRepo.AddRepositories(types.Repository{Name: strings.Join([]string{parts[0], parts[1], parts[2]}, "/")}) 42 | } 43 | 44 | return pkgRepo, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/bom/purl_test.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/jetstack/tally/internal/types" 9 | ) 10 | 11 | func TestPackageRepositoriesFromPurl(t *testing.T) { 12 | testCases := []struct { 13 | purl string 14 | wantPackageRepositories *types.PackageRepositories 15 | wantErr error 16 | }{ 17 | { 18 | purl: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 19 | wantPackageRepositories: &types.PackageRepositories{ 20 | Package: types.Package{ 21 | Type: "maven", 22 | Name: "org.hdrhistogram/HdrHistogram", 23 | }, 24 | }, 25 | }, 26 | { 27 | purl: "pkg:golang/sigs.k8s.io/release-utils@v0.7.3", 28 | wantPackageRepositories: &types.PackageRepositories{ 29 | Package: types.Package{ 30 | Type: "golang", 31 | Name: "sigs.k8s.io/release-utils", 32 | }, 33 | }, 34 | }, 35 | { 36 | purl: "pkg:golang/github.com/foo/bar@v0.7.3", 37 | wantPackageRepositories: &types.PackageRepositories{ 38 | Package: types.Package{ 39 | Type: "golang", 40 | Name: "github.com/foo/bar", 41 | }, 42 | Repositories: []types.Repository{ 43 | { 44 | Name: "github.com/foo/bar", 45 | }, 46 | }, 47 | }, 48 | }, 49 | { 50 | purl: "pkg:npm/zwitch@2.0.2", 51 | wantPackageRepositories: &types.PackageRepositories{ 52 | Package: types.Package{ 53 | Type: "npm", 54 | Name: "zwitch", 55 | }, 56 | }, 57 | }, 58 | { 59 | purl: "pkg:cargo/getrandom@0.2.7", 60 | wantPackageRepositories: &types.PackageRepositories{ 61 | Package: types.Package{ 62 | Type: "cargo", 63 | Name: "getrandom", 64 | }, 65 | }, 66 | }, 67 | { 68 | purl: "pkg:pypi/zope.interface@5.4.0", 69 | wantPackageRepositories: &types.PackageRepositories{ 70 | Package: types.Package{ 71 | Type: "pypi", 72 | Name: "zope.interface", 73 | }, 74 | }, 75 | }, 76 | { 77 | purl: "pkg:pypi/foo.bar@5.4.0?vcs_url=git+git+ssh://git@github.com:foo/bar.git#v5.4.0", 78 | wantPackageRepositories: &types.PackageRepositories{ 79 | Package: types.Package{ 80 | Type: "pypi", 81 | Name: "foo.bar", 82 | }, 83 | Repositories: []types.Repository{ 84 | { 85 | Name: "github.com/foo/bar", 86 | }, 87 | }, 88 | }, 89 | }, 90 | } 91 | for _, tc := range testCases { 92 | gotPkg, err := packageRepositoriesFromPurl(tc.purl) 93 | if !errors.Is(err, tc.wantErr) { 94 | t.Fatalf("unexpected error; wanted %s but got %s", tc.wantErr, err) 95 | } 96 | 97 | if diff := cmp.Diff(tc.wantPackageRepositories, gotPkg); diff != "" { 98 | t.Errorf("unexpected package:\n%s", diff) 99 | } 100 | 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /internal/bom/syft.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/anchore/syft/syft/formats/syftjson/model" 8 | syft "github.com/anchore/syft/syft/pkg" 9 | github_url "github.com/jetstack/tally/internal/github-url" 10 | "github.com/jetstack/tally/internal/types" 11 | ) 12 | 13 | // ParseSyftBOM parses a syft SBOM 14 | func ParseSyftBOM(r io.Reader) (*model.Document, error) { 15 | data, err := io.ReadAll(r) 16 | if err != nil { 17 | return nil, err 18 | } 19 | doc := &model.Document{} 20 | if err := json.Unmarshal(data, doc); err != nil { 21 | return nil, err 22 | } 23 | 24 | return doc, nil 25 | } 26 | 27 | // PackageRepositoriesFromSyftBOM discovers packages in a Syft BOM 28 | func PackageRepositoriesFromSyftBOM(doc *model.Document) ([]*types.PackageRepositories, error) { 29 | var pkgRepos []*types.PackageRepositories 30 | for _, a := range doc.Artifacts { 31 | pkgRepo, err := packageRepositoriesFromSyftPackage(a) 32 | if err != nil { 33 | return nil, err 34 | } 35 | if pkgRepo == nil { 36 | continue 37 | } 38 | 39 | pkgRepos = appendPackageRepositories(pkgRepos, pkgRepo) 40 | } 41 | 42 | return pkgRepos, nil 43 | } 44 | 45 | func packageRepositoriesFromSyftPackage(pkg model.Package) (*types.PackageRepositories, error) { 46 | if pkg.PURL == "" { 47 | return nil, nil 48 | } 49 | 50 | pkgRepo, err := packageRepositoriesFromPurl(pkg.PURL) 51 | if err != nil { 52 | return nil, err 53 | } 54 | if pkgRepo == nil { 55 | return nil, nil 56 | } 57 | 58 | pkgRepo.AddRepositories(repositoriesFromSyftPackage(pkg)...) 59 | 60 | return pkgRepo, nil 61 | } 62 | 63 | func repositoriesFromSyftPackage(pkg model.Package) []types.Repository { 64 | var repos []types.Repository 65 | switch pkg.MetadataType { 66 | case syft.DartPubMetadataType: 67 | metadata, ok := pkg.Metadata.(syft.DartPubMetadata) 68 | if ok { 69 | repo := github_url.ToRepository(metadata.VcsURL) 70 | if repo != nil { 71 | repos = append(repos, *repo) 72 | } 73 | } 74 | case syft.GemMetadataType: 75 | metadata, ok := pkg.Metadata.(syft.GemMetadata) 76 | if ok { 77 | repo := github_url.ToRepository(metadata.Homepage) 78 | if repo != nil { 79 | repos = append(repos, *repo) 80 | } 81 | } 82 | case syft.PhpComposerJSONMetadataType: 83 | metadata, ok := pkg.Metadata.(syft.PhpComposerJSONMetadata) 84 | if ok { 85 | repo := github_url.ToRepository(metadata.Source.URL) 86 | if repo != nil { 87 | repos = append(repos, *repo) 88 | } 89 | } 90 | case syft.NpmPackageJSONMetadataType: 91 | metadata, ok := pkg.Metadata.(syft.NpmPackageJSONMetadata) 92 | if ok { 93 | repo := github_url.ToRepository(metadata.Homepage) 94 | if repo != nil { 95 | repos = append(repos, *repo) 96 | } 97 | repo = github_url.ToRepository(metadata.URL) 98 | if repo != nil { 99 | repos = append(repos, *repo) 100 | } 101 | } 102 | case syft.PythonPackageMetadataType: 103 | metadata, ok := pkg.Metadata.(syft.PythonPackageMetadata) 104 | if ok { 105 | if metadata.DirectURLOrigin != nil { 106 | repo := github_url.ToRepository(metadata.DirectURLOrigin.URL) 107 | if repo != nil { 108 | repos = append(repos, *repo) 109 | } 110 | } 111 | } 112 | 113 | } 114 | 115 | return repos 116 | } 117 | 118 | func appendPackageRepositories(pkgRepos []*types.PackageRepositories, pkgRepo *types.PackageRepositories) []*types.PackageRepositories { 119 | for _, p := range pkgRepos { 120 | if !p.Equals(pkgRepo.Package) { 121 | continue 122 | } 123 | 124 | p.AddRepositories(pkgRepo.Repositories...) 125 | 126 | return pkgRepos 127 | } 128 | 129 | return append(pkgRepos, pkgRepo) 130 | } 131 | -------------------------------------------------------------------------------- /internal/bom/syft_test.go: -------------------------------------------------------------------------------- 1 | package bom 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/anchore/syft/syft/formats/syftjson/model" 8 | "github.com/anchore/syft/syft/pkg" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/jetstack/tally/internal/types" 11 | ) 12 | 13 | func TestParseSyftBOM(t *testing.T) { 14 | testCases := map[string]struct { 15 | path string 16 | wantBOM *model.Document 17 | wantErr bool 18 | }{ 19 | "json is parsed successfully": { 20 | path: "testdata/syft.json", 21 | wantBOM: &model.Document{ 22 | Artifacts: []model.Package{ 23 | { 24 | PackageBasicData: model.PackageBasicData{ 25 | ID: "0", 26 | Name: "foo", 27 | PURL: "pkg:golang/foo/bar@v0.2.5", 28 | }, 29 | }, 30 | { 31 | PackageBasicData: model.PackageBasicData{ 32 | ID: "1", 33 | Name: "HdrHistogram", 34 | PURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 35 | }, 36 | }, 37 | }, 38 | }, 39 | }, 40 | "error is returned when parsing invalid json": { 41 | path: "testdata/syft.json.invalid", 42 | wantErr: true, 43 | }, 44 | } 45 | for n, tc := range testCases { 46 | t.Run(n, func(t *testing.T) { 47 | r, err := os.Open(tc.path) 48 | if err != nil { 49 | t.Fatalf("unexpected error opening file: %s", err) 50 | } 51 | defer r.Close() 52 | 53 | gotBOM, err := ParseSyftBOM(r) 54 | if err != nil && !tc.wantErr { 55 | t.Fatalf("unexpected error parsing BOM: %s", err) 56 | } 57 | if err == nil && tc.wantErr { 58 | t.Fatalf("expected parsing BOM but got nil") 59 | } 60 | 61 | if tc.wantErr { 62 | return 63 | } 64 | 65 | if diff := cmp.Diff(tc.wantBOM, gotBOM); diff != "" { 66 | t.Errorf("unexpected BOM:\n%s", diff) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestPackageRepositoriesFromSyftBOM(t *testing.T) { 73 | testCases := map[string]struct { 74 | bom *model.Document 75 | wantPackages []*types.PackageRepositories 76 | }{ 77 | "an error should not be produced for an empty BOM": { 78 | bom: &model.Document{}, 79 | }, 80 | "components without a PURL should be ignored": { 81 | bom: &model.Document{ 82 | Artifacts: []model.Package{ 83 | { 84 | PackageBasicData: model.PackageBasicData{ 85 | Name: "foo", 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | "duplicate packages should be ignored": { 92 | bom: &model.Document{ 93 | Artifacts: []model.Package{ 94 | { 95 | 96 | PackageBasicData: model.PackageBasicData{ 97 | PURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.8", 98 | }, 99 | }, 100 | { 101 | 102 | PackageBasicData: model.PackageBasicData{ 103 | PURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 104 | }, 105 | }, 106 | { 107 | 108 | PackageBasicData: model.PackageBasicData{ 109 | PURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 110 | }, 111 | }, 112 | }, 113 | }, 114 | wantPackages: []*types.PackageRepositories{ 115 | { 116 | Package: types.Package{ 117 | Type: "maven", 118 | Name: "org.hdrhistogram/HdrHistogram", 119 | }, 120 | }, 121 | }, 122 | }, 123 | "multiple types should be discovered": { 124 | bom: &model.Document{ 125 | Artifacts: []model.Package{ 126 | { 127 | 128 | PackageBasicData: model.PackageBasicData{ 129 | PURL: "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9", 130 | }, 131 | }, 132 | { 133 | 134 | PackageBasicData: model.PackageBasicData{ 135 | PURL: "pkg:golang/sigs.k8s.io/release-utils@v0.7.3", 136 | }, 137 | }, 138 | { 139 | 140 | PackageBasicData: model.PackageBasicData{ 141 | PURL: "pkg:npm/zwitch@2.0.2", 142 | }, 143 | }, 144 | { 145 | 146 | PackageBasicData: model.PackageBasicData{ 147 | PURL: "pkg:cargo/getrandom@0.2.7", 148 | }, 149 | }, 150 | { 151 | 152 | PackageBasicData: model.PackageBasicData{ 153 | PURL: "pkg:pypi/zope.interface@5.4.0", 154 | }, 155 | }, 156 | }, 157 | }, 158 | wantPackages: []*types.PackageRepositories{ 159 | { 160 | Package: types.Package{ 161 | Type: "maven", 162 | Name: "org.hdrhistogram/HdrHistogram", 163 | }, 164 | }, 165 | { 166 | Package: types.Package{ 167 | Type: "golang", 168 | Name: "sigs.k8s.io/release-utils", 169 | }, 170 | }, 171 | { 172 | Package: types.Package{ 173 | Type: "npm", 174 | Name: "zwitch", 175 | }, 176 | }, 177 | { 178 | Package: types.Package{ 179 | Type: "cargo", 180 | Name: "getrandom", 181 | }, 182 | }, 183 | { 184 | Package: types.Package{ 185 | Type: "pypi", 186 | Name: "zope.interface", 187 | }, 188 | }, 189 | }, 190 | }, 191 | "should discover repositories from supported package types": { 192 | bom: &model.Document{ 193 | Artifacts: []model.Package{ 194 | { 195 | 196 | PackageBasicData: model.PackageBasicData{ 197 | PURL: "pkg:pub/foobar@3.3.0?hosted_url=pub.hosted.org", 198 | }, 199 | PackageCustomData: model.PackageCustomData{ 200 | MetadataType: pkg.DartPubMetadataType, 201 | Metadata: pkg.DartPubMetadata{ 202 | VcsURL: "github.com/foo/bar", 203 | }, 204 | }, 205 | }, 206 | { 207 | 208 | PackageBasicData: model.PackageBasicData{ 209 | PURL: "pkg:gem/foobar@2.1.4", 210 | }, 211 | PackageCustomData: model.PackageCustomData{ 212 | MetadataType: pkg.GemMetadataType, 213 | Metadata: pkg.GemMetadata{ 214 | Homepage: "https://github.com/foo/bar", 215 | }, 216 | }, 217 | }, 218 | { 219 | 220 | PackageBasicData: model.PackageBasicData{ 221 | PURL: "pkg:composer/foo/bar@1.0.2", 222 | }, 223 | PackageCustomData: model.PackageCustomData{ 224 | MetadataType: pkg.PhpComposerJSONMetadataType, 225 | Metadata: pkg.PhpComposerJSONMetadata{ 226 | Source: pkg.PhpComposerExternalReference{ 227 | Type: "git", 228 | URL: "https://github.com/foo/bar.git", 229 | Reference: "6d9a552f0206a1db7feb442824540aa6c55e5b27", 230 | }, 231 | }, 232 | }, 233 | }, 234 | { 235 | 236 | PackageBasicData: model.PackageBasicData{ 237 | PURL: "pkg:npm/foobar@6.14.6", 238 | }, 239 | PackageCustomData: model.PackageCustomData{ 240 | MetadataType: pkg.NpmPackageJSONMetadataType, 241 | Metadata: pkg.NpmPackageJSONMetadata{ 242 | Homepage: "https://github.com/foo/bar", 243 | }, 244 | }, 245 | }, 246 | { 247 | 248 | PackageBasicData: model.PackageBasicData{ 249 | PURL: "pkg:npm/foobar1@6.14.6", 250 | }, 251 | PackageCustomData: model.PackageCustomData{ 252 | MetadataType: pkg.NpmPackageJSONMetadataType, 253 | Metadata: pkg.NpmPackageJSONMetadata{ 254 | Homepage: "https://docs.npmjs.com/", 255 | URL: "https://github.com/foo/bar1", 256 | }, 257 | }, 258 | }, 259 | { 260 | 261 | PackageBasicData: model.PackageBasicData{ 262 | PURL: "pkg:npm/barfoo@6.14.6", 263 | }, 264 | PackageCustomData: model.PackageCustomData{ 265 | MetadataType: pkg.NpmPackageJSONMetadataType, 266 | Metadata: pkg.NpmPackageJSONMetadata{ 267 | Homepage: "https://github.com/bar/foo", 268 | URL: "https://github.com/foo/bar", 269 | }, 270 | }, 271 | }, 272 | { 273 | 274 | PackageBasicData: model.PackageBasicData{ 275 | PURL: "pkg:pypi/foobar@v0.1.0", 276 | }, 277 | PackageCustomData: model.PackageCustomData{ 278 | MetadataType: pkg.PythonPackageMetadataType, 279 | Metadata: pkg.PythonPackageMetadata{ 280 | DirectURLOrigin: &pkg.PythonDirectURLOriginInfo{ 281 | VCS: "git", 282 | URL: "https://github.com/foo/bar.git", 283 | CommitID: "6d9a552f0206a1db7feb442824540aa6c55e5b27", 284 | }, 285 | }, 286 | }, 287 | }, 288 | { 289 | 290 | PackageBasicData: model.PackageBasicData{ 291 | PURL: "pkg:pypi/foo.bar@5.4.0?vcs_url=git+git+ssh://git@github.com:foo/bar.git#v5.4.0", 292 | }, 293 | PackageCustomData: model.PackageCustomData{ 294 | MetadataType: pkg.PythonPackageMetadataType, 295 | Metadata: pkg.PythonPackageMetadata{}, 296 | }, 297 | }, 298 | }, 299 | }, 300 | wantPackages: []*types.PackageRepositories{ 301 | { 302 | Package: types.Package{ 303 | Type: "pub", 304 | Name: "foobar", 305 | }, 306 | Repositories: []types.Repository{ 307 | { 308 | Name: "github.com/foo/bar", 309 | }, 310 | }, 311 | }, 312 | { 313 | Package: types.Package{ 314 | Type: "gem", 315 | Name: "foobar", 316 | }, 317 | Repositories: []types.Repository{ 318 | { 319 | Name: "github.com/foo/bar", 320 | }, 321 | }, 322 | }, 323 | { 324 | Package: types.Package{ 325 | Type: "composer", 326 | Name: "foo/bar", 327 | }, 328 | Repositories: []types.Repository{ 329 | { 330 | Name: "github.com/foo/bar", 331 | }, 332 | }, 333 | }, 334 | { 335 | Package: types.Package{ 336 | Type: "npm", 337 | Name: "foobar", 338 | }, 339 | Repositories: []types.Repository{ 340 | { 341 | Name: "github.com/foo/bar", 342 | }, 343 | }, 344 | }, 345 | { 346 | Package: types.Package{ 347 | Type: "npm", 348 | Name: "foobar1", 349 | }, 350 | Repositories: []types.Repository{ 351 | { 352 | Name: "github.com/foo/bar1", 353 | }, 354 | }, 355 | }, 356 | { 357 | Package: types.Package{ 358 | Type: "npm", 359 | Name: "barfoo", 360 | }, 361 | Repositories: []types.Repository{ 362 | { 363 | Name: "github.com/bar/foo", 364 | }, 365 | { 366 | Name: "github.com/foo/bar", 367 | }, 368 | }, 369 | }, 370 | { 371 | Package: types.Package{ 372 | Type: "pypi", 373 | Name: "foobar", 374 | }, 375 | Repositories: []types.Repository{ 376 | { 377 | Name: "github.com/foo/bar", 378 | }, 379 | }, 380 | }, 381 | { 382 | Package: types.Package{ 383 | Type: "pypi", 384 | Name: "foo.bar", 385 | }, 386 | Repositories: []types.Repository{ 387 | { 388 | Name: "github.com/foo/bar", 389 | }, 390 | }, 391 | }, 392 | }, 393 | }, 394 | } 395 | for n, tc := range testCases { 396 | t.Run(n, func(t *testing.T) { 397 | 398 | gotPackages, err := PackageRepositoriesFromSyftBOM(tc.bom) 399 | if err != nil { 400 | t.Fatalf("unexpected error getting packages from bom: %s", err) 401 | } 402 | if diff := cmp.Diff(tc.wantPackages, gotPackages); diff != "" { 403 | t.Errorf("unexpected packages:\n%s", diff) 404 | } 405 | }) 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /internal/bom/testdata/cdx.json: -------------------------------------------------------------------------------- 1 | { 2 | "bomFormat": "CycloneDX", 3 | "specVersion": "1.4", 4 | "serialNumber": "urn:uuid:5e0841b1-88e1-4dd8-b706-77457fb3e779", 5 | "version": 1, 6 | "metadata": { 7 | "component": { 8 | "bom-ref": "1234567", 9 | "type": "application", 10 | "name": "foo/bar", 11 | "version": "v0.2.5", 12 | "purl": "pkg:golang/foo/bar@v0.2.5" 13 | } 14 | }, 15 | "components": [ 16 | { 17 | "bom-ref": "0", 18 | "type": "library", 19 | "name": "HdrHistogram", 20 | "purl": "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9" 21 | }, 22 | { 23 | "bom-ref": "1", 24 | "type": "library", 25 | "name": "adduser", 26 | "purl": "pkg:deb/debian/adduser@3.118?arch=all\u0026distro=debian-11" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /internal/bom/testdata/cdx.json.invalid: -------------------------------------------------------------------------------- 1 | { 2 | "bomFormat": "CycloneDX" 3 | "specVersion": "1.4", 4 | "serialNumber": "urn:uuid:5e0841b1-88e1-4dd8-b706-77457fb3e779", 5 | "version": 1, 6 | "metadata": { 7 | "component": { 8 | "bom-ref": "1234567", 9 | "type": "application", 10 | "name": "foo/bar", 11 | "version": "v0.2.5", 12 | "purl": "pkg:golang/foo/bar@v0.2.5" 13 | } 14 | }, 15 | "components": [ 16 | { 17 | "bom-ref": "0", 18 | "type": "library", 19 | "name": "HdrHistogram", 20 | "purl": "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9" 21 | }, 22 | { 23 | "bom-ref": "1", 24 | "type": "library", 25 | "name": "adduser", 26 | "purl": "pkg:deb/debian/adduser@3.118?arch=all\u0026distro=debian-11" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /internal/bom/testdata/cdx.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | foo/bar 6 | v0.2.5 7 | pkg:golang/foo/bar@v0.2.5 8 | 9 | 10 | 11 | 12 | HdrHistogram 13 | pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9 14 | 15 | 16 | adduser 17 | pkg:deb/debian/adduser@3.118?arch=all&distro=debian-11 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /internal/bom/testdata/cdx.xml.invalid: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | foo/bar 6 | v0.2.5 7 | pkg:golang/foo/bar@v0.2.5 8 | 9 | 10 | 11 | HdrHistogram 12 | pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9 13 | 14 | 15 | adduser 16 | pkg:deb/debian/adduser@3.118?arch=all&distro=debian-11 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /internal/bom/testdata/syft.json: -------------------------------------------------------------------------------- 1 | { 2 | "artifacts": [ 3 | { 4 | "id": "0", 5 | "name": "foo", 6 | "purl": "pkg:golang/foo/bar@v0.2.5" 7 | }, 8 | { 9 | "id": "1", 10 | "name": "HdrHistogram", 11 | "purl": "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /internal/bom/testdata/syft.json.invalid: -------------------------------------------------------------------------------- 1 | { 2 | "artifacts": [ 3 | { 4 | "id": "0", 5 | "name": "foo" 6 | "purl": "pkg:golang/foo/bar@v0.2.5" 7 | }, 8 | { 9 | "id": "1", 10 | "name": "HdrHistogram", 11 | "purl": "pkg:maven/org.hdrhistogram/HdrHistogram@2.1.9" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /internal/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/ossf/scorecard-webapp/app/generated/models" 8 | _ "modernc.org/sqlite" 9 | ) 10 | 11 | // ErrNotFound is returned when a result isn't found in the cache 12 | var ErrNotFound = errors.New("not found in cache") 13 | 14 | // Cache caches results 15 | type Cache interface { 16 | // GetResult retrieves a scorecard result from the cache 17 | GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) 18 | 19 | // PutResult inserts a score into the cache 20 | PutResult(ctx context.Context, repository string, result *models.ScorecardResult) error 21 | } 22 | -------------------------------------------------------------------------------- /internal/cache/options.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import "time" 4 | 5 | // Option is a functional option that configures a cache 6 | type Option func(o *options) 7 | 8 | type options struct { 9 | Duration time.Duration 10 | } 11 | 12 | func makeOptions(opts ...Option) *options { 13 | o := &options{ 14 | Duration: (24 * time.Hour) * 7, 15 | } 16 | for _, opt := range opts { 17 | opt(o) 18 | } 19 | 20 | return o 21 | } 22 | 23 | // WithDuration is a functional option that configures the amount of time until 24 | // an item in the cache is invalidated 25 | func WithDuration(d time.Duration) Option { 26 | return func(o *options) { 27 | o.Duration = d 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /internal/cache/scorecard_client.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/jetstack/tally/internal/scorecard" 9 | "github.com/ossf/scorecard-webapp/app/generated/models" 10 | ) 11 | 12 | // ScorecardClient wraps another scorecard client, caching the scores it retrieves 13 | type ScorecardClient struct { 14 | ca Cache 15 | scorecard.Client 16 | } 17 | 18 | // NewScorecardClient returns a scorecard client that caches scores from another client 19 | func NewScorecardClient(ca Cache, client scorecard.Client) scorecard.Client { 20 | return &ScorecardClient{ 21 | ca: ca, 22 | Client: client, 23 | } 24 | } 25 | 26 | // GetResult attempts to get the scorecard result from the cache. Failing that it will get 27 | // the scorecard result from the wrapped client and cache it for next time. 28 | func (c *ScorecardClient) GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) { 29 | result, err := c.ca.GetResult(ctx, repository) 30 | if err == nil { 31 | return result, nil 32 | } 33 | if !errors.Is(err, ErrNotFound) { 34 | return nil, fmt.Errorf("getting scorecard result from cache: %w", err) 35 | } 36 | 37 | result, err = c.Client.GetResult(ctx, repository) 38 | if err != nil { 39 | return nil, fmt.Errorf("getting scorecard result from wrapped client: %w", err) 40 | } 41 | 42 | if err := c.ca.PutResult(ctx, repository, result); err != nil { 43 | return nil, fmt.Errorf("caching scorecard result: %w", err) 44 | } 45 | 46 | return result, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/cache/scorecard_client_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/jetstack/tally/internal/scorecard" 10 | "github.com/ossf/scorecard-webapp/app/generated/models" 11 | ) 12 | 13 | type mockCache struct { 14 | repoToScorecardResult map[string]*models.ScorecardResult 15 | putErr error 16 | getErr error 17 | } 18 | 19 | func (c *mockCache) GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) { 20 | if c.getErr != nil { 21 | return nil, c.getErr 22 | } 23 | 24 | score, ok := c.repoToScorecardResult[repository] 25 | if !ok { 26 | return nil, ErrNotFound 27 | } 28 | 29 | return score, nil 30 | } 31 | 32 | func (c *mockCache) PutResult(ctx context.Context, repository string, result *models.ScorecardResult) error { 33 | if c.putErr != nil { 34 | return c.putErr 35 | } 36 | 37 | c.repoToScorecardResult[repository] = result 38 | 39 | return nil 40 | } 41 | 42 | type mockScorecardClient struct { 43 | repoToScorecardResult map[string]*models.ScorecardResult 44 | getErr error 45 | name string 46 | } 47 | 48 | func (c *mockScorecardClient) Name() string { 49 | return c.name 50 | } 51 | 52 | func (c *mockScorecardClient) GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) { 53 | if c.getErr != nil { 54 | return nil, c.getErr 55 | } 56 | 57 | result, ok := c.repoToScorecardResult[repository] 58 | if !ok { 59 | return nil, ErrNotFound 60 | } 61 | 62 | return result, nil 63 | } 64 | 65 | func TestScorecardClientGetScore(t *testing.T) { 66 | type testCase struct { 67 | repository string 68 | cache Cache 69 | scorecardClient scorecard.Client 70 | wantScorecardResult *models.ScorecardResult 71 | wantErr error 72 | } 73 | testCases := map[string]func(t *testing.T) *testCase{ 74 | "should return score from cache": func(t *testing.T) *testCase { 75 | repository := "github.com/foo/bar" 76 | 77 | wantScorecardResult := &models.ScorecardResult{ 78 | Repo: &models.Repo{ 79 | Name: repository, 80 | }, 81 | Score: 7.2, 82 | Checks: []*models.ScorecardCheck{ 83 | { 84 | Name: "foo", 85 | Score: 8, 86 | }, 87 | { 88 | Name: "bar", 89 | Score: 2, 90 | }, 91 | }, 92 | } 93 | return &testCase{ 94 | repository: repository, 95 | cache: &mockCache{ 96 | repoToScorecardResult: map[string]*models.ScorecardResult{ 97 | repository: wantScorecardResult, 98 | }, 99 | }, 100 | scorecardClient: &mockScorecardClient{ 101 | repoToScorecardResult: map[string]*models.ScorecardResult{ 102 | repository: { 103 | Score: 2.1, 104 | }, 105 | }, 106 | }, 107 | wantScorecardResult: wantScorecardResult, 108 | } 109 | }, 110 | "should return score from client when cache returns ErrNotFound": func(t *testing.T) *testCase { 111 | repository := "github.com/foo/bar" 112 | wantScorecardResult := &models.ScorecardResult{ 113 | Repo: &models.Repo{ 114 | Name: repository, 115 | }, 116 | Score: 7.2, 117 | Checks: []*models.ScorecardCheck{ 118 | { 119 | Name: "foo", 120 | Score: 8, 121 | }, 122 | { 123 | Name: "bar", 124 | Score: 2, 125 | }, 126 | }, 127 | } 128 | return &testCase{ 129 | repository: repository, 130 | cache: &mockCache{ 131 | repoToScorecardResult: map[string]*models.ScorecardResult{}, 132 | }, 133 | scorecardClient: &mockScorecardClient{ 134 | repoToScorecardResult: map[string]*models.ScorecardResult{ 135 | repository: wantScorecardResult, 136 | }, 137 | }, 138 | wantScorecardResult: wantScorecardResult, 139 | } 140 | }, 141 | "should return error from cache": func(t *testing.T) *testCase { 142 | repository := "github.com/foo/bar" 143 | wantErr := errors.New("foobar") 144 | return &testCase{ 145 | repository: repository, 146 | cache: &mockCache{ 147 | getErr: wantErr, 148 | }, 149 | scorecardClient: &mockScorecardClient{}, 150 | wantErr: wantErr, 151 | } 152 | }, 153 | "should return error from client": func(t *testing.T) *testCase { 154 | repository := "github.com/foo/bar" 155 | wantErr := errors.New("foobar") 156 | return &testCase{ 157 | repository: repository, 158 | cache: &mockCache{}, 159 | scorecardClient: &mockScorecardClient{ 160 | getErr: wantErr, 161 | }, 162 | wantErr: wantErr, 163 | } 164 | }, 165 | "shouldn't return error when cache has score": func(t *testing.T) *testCase { 166 | repository := "github.com/foo/bar" 167 | wantScorecardResult := &models.ScorecardResult{ 168 | Repo: &models.Repo{ 169 | Name: repository, 170 | }, 171 | Score: 7.2, 172 | Checks: []*models.ScorecardCheck{ 173 | { 174 | Name: "foo", 175 | Score: 8, 176 | }, 177 | { 178 | Name: "bar", 179 | Score: 2, 180 | }, 181 | }, 182 | } 183 | return &testCase{ 184 | repository: repository, 185 | cache: &mockCache{ 186 | repoToScorecardResult: map[string]*models.ScorecardResult{ 187 | repository: wantScorecardResult, 188 | }, 189 | }, 190 | scorecardClient: &mockScorecardClient{ 191 | getErr: errors.New("foobar"), 192 | }, 193 | wantScorecardResult: wantScorecardResult, 194 | } 195 | }, 196 | "should return ErrNotFound when score not found in cache or client": func(t *testing.T) *testCase { 197 | repository := "github.com/foo/bar" 198 | return &testCase{ 199 | repository: repository, 200 | cache: &mockCache{}, 201 | scorecardClient: &mockScorecardClient{}, 202 | wantErr: ErrNotFound, 203 | } 204 | }, 205 | } 206 | for n, setup := range testCases { 207 | t.Run(n, func(t *testing.T) { 208 | tc := setup(t) 209 | 210 | gotScorecardResult, err := NewScorecardClient(tc.cache, tc.scorecardClient).GetResult(context.Background(), tc.repository) 211 | if !errors.Is(err, tc.wantErr) { 212 | t.Fatalf("unexpected error: %s", err) 213 | } 214 | if diff := cmp.Diff(tc.wantScorecardResult, gotScorecardResult); diff != "" { 215 | t.Errorf("unexpected score:\n%s", diff) 216 | } 217 | }) 218 | } 219 | } 220 | 221 | func TestScorecardClientName(t *testing.T) { 222 | wantName := "foobar" 223 | gotName := NewScorecardClient(&mockCache{}, &mockScorecardClient{name: wantName}).Name() 224 | if gotName != wantName { 225 | t.Errorf("unexpected name returned by caching client; wanted %s but got %s", wantName, gotName) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /internal/cache/sqlite.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | "sync" 12 | "time" 13 | 14 | "github.com/ossf/scorecard-webapp/app/generated/models" 15 | _ "modernc.org/sqlite" 16 | ) 17 | 18 | const ( 19 | createTableStatement = ` 20 | CREATE TABLE IF NOT EXISTS results ( 21 | repository text NOT NULL UNIQUE, 22 | result text NOT NULL, 23 | timestamp DATETIME NOT NULL 24 | ); 25 | ` 26 | 27 | selectResultQuery = ` 28 | SELECT result, timestamp 29 | FROM results 30 | WHERE repository = ?; 31 | ` 32 | 33 | insertResultStatement = ` 34 | INSERT or REPLACE INTO results 35 | (repository, result, timestamp) 36 | VALUES (?, ?, ?) 37 | ` 38 | ) 39 | 40 | type sqliteCache struct { 41 | db *sql.DB 42 | opts *options 43 | timeNow func() time.Time 44 | mux *sync.RWMutex 45 | } 46 | 47 | // NewSqliteCache returns a cache implementation that stores scorecard scores in a 48 | // local sqlite database 49 | func NewSqliteCache(dbDir string, opts ...Option) (Cache, error) { 50 | o := makeOptions(opts...) 51 | 52 | // If the directory isn't defined, then default to the users home cache 53 | // dir 54 | if dbDir == "" { 55 | cacheDir, err := os.UserCacheDir() 56 | if err != nil { 57 | return nil, fmt.Errorf("getting user cache dir: %w", err) 58 | } 59 | dbDir = filepath.Join(cacheDir, "tally", "cache") 60 | } 61 | if err := os.MkdirAll(dbDir, os.ModePerm); err != nil { 62 | return nil, fmt.Errorf("creating cache directory: %w", err) 63 | } 64 | 65 | db, err := sql.Open("sqlite", fmt.Sprintf("%s/%s", dbDir, "cache.db")) 66 | if err != nil { 67 | return nil, err 68 | } 69 | if _, err := db.Exec(createTableStatement); err != nil { 70 | return nil, fmt.Errorf("creating scores table in database: %w", err) 71 | } 72 | 73 | return &sqliteCache{ 74 | db: db, 75 | opts: o, 76 | mux: &sync.RWMutex{}, 77 | timeNow: time.Now, 78 | }, nil 79 | } 80 | 81 | // GetResult will retrieve a scorecard result from the cache 82 | func (c *sqliteCache) GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) { 83 | c.mux.Lock() 84 | defer c.mux.Unlock() 85 | 86 | rows, err := c.db.QueryContext(ctx, selectResultQuery, repository) 87 | if errors.Is(err, sql.ErrNoRows) { 88 | return nil, ErrNotFound 89 | } 90 | if err != nil { 91 | return nil, fmt.Errorf("getting score from database: %w", err) 92 | } 93 | defer rows.Close() 94 | 95 | type row struct { 96 | Result []byte 97 | Timestamp time.Time 98 | } 99 | var resp []row 100 | for rows.Next() { 101 | var r row 102 | if err := rows.Scan(&r.Result, &r.Timestamp); err != nil { 103 | return nil, fmt.Errorf("scanning row: %w", err) 104 | } 105 | resp = append(resp, r) 106 | } 107 | if len(resp) != 1 { 108 | return nil, ErrNotFound 109 | } 110 | 111 | if resp[0].Timestamp.Add(c.opts.Duration).Before(c.timeNow()) { 112 | return nil, ErrNotFound 113 | } 114 | 115 | result := &models.ScorecardResult{} 116 | if err := json.Unmarshal(resp[0].Result, result); err != nil { 117 | return nil, fmt.Errorf("unmarshaling score from json: %w", err) 118 | } 119 | 120 | return result, nil 121 | } 122 | 123 | // PutResult will put a scorecard result into the cache 124 | func (c *sqliteCache) PutResult(ctx context.Context, repository string, result *models.ScorecardResult) error { 125 | c.mux.Lock() 126 | defer c.mux.Unlock() 127 | 128 | scoreData, err := json.Marshal(result) 129 | if err != nil { 130 | return fmt.Errorf("marshaling score to JSON: %w", err) 131 | } 132 | if _, err := c.db.ExecContext( 133 | ctx, 134 | insertResultStatement, 135 | repository, 136 | scoreData, 137 | c.timeNow(), 138 | ); err != nil { 139 | return fmt.Errorf("inserting score: %w", err) 140 | } 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /internal/cache/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/ossf/scorecard-webapp/app/generated/models" 11 | ) 12 | 13 | func TestSqliteCachePutGet(t *testing.T) { 14 | tmpDir := t.TempDir() 15 | 16 | cache, err := NewSqliteCache(tmpDir) 17 | if err != nil { 18 | t.Fatalf("unexpected error creating cache: %s", err) 19 | } 20 | 21 | repository := "github.com/foo/bar" 22 | 23 | wantScorecardResult := &models.ScorecardResult{ 24 | Score: 5.5, 25 | } 26 | 27 | if err := cache.PutResult(context.Background(), repository, wantScorecardResult); err != nil { 28 | t.Fatalf("unexpected error putting score in cache: %s", err) 29 | } 30 | 31 | gotScorecardResult, err := cache.GetResult(context.Background(), repository) 32 | if err != nil { 33 | t.Fatalf("unexpected error retrieving score from cache: %s", err) 34 | } 35 | 36 | if diff := cmp.Diff(wantScorecardResult, gotScorecardResult); diff != "" { 37 | t.Fatalf("unexpected score:\n%s", diff) 38 | } 39 | } 40 | 41 | func TestSqliteCachePutGet_Replace(t *testing.T) { 42 | tmpDir := t.TempDir() 43 | 44 | cache, err := NewSqliteCache(tmpDir) 45 | if err != nil { 46 | t.Fatalf("unexpected error creating cache: %s", err) 47 | } 48 | 49 | repository := "github.com/foo/bar" 50 | 51 | // Put the first score for the repository in 52 | wantScorecardResult := &models.ScorecardResult{ 53 | Score: 5.5, 54 | } 55 | if err := cache.PutResult(context.Background(), repository, wantScorecardResult); err != nil { 56 | t.Fatalf("unexpected error putting score in cache: %s", err) 57 | } 58 | gotScorecardResult, err := cache.GetResult(context.Background(), repository) 59 | if err != nil { 60 | t.Fatalf("unexpected error retrieving score from cache: %s", err) 61 | } 62 | if diff := cmp.Diff(wantScorecardResult, gotScorecardResult); diff != "" { 63 | t.Fatalf("unexpected score:\n%s", diff) 64 | } 65 | 66 | // Update the score 67 | wantScorecardResult = &models.ScorecardResult{ 68 | Score: 7.7, 69 | } 70 | if err := cache.PutResult(context.Background(), repository, wantScorecardResult); err != nil { 71 | t.Fatalf("unexpected error putting score in cache: %s", err) 72 | } 73 | gotScorecardResult, err = cache.GetResult(context.Background(), repository) 74 | if err != nil { 75 | t.Fatalf("unexpected error retrieving score from cache: %s", err) 76 | } 77 | if diff := cmp.Diff(wantScorecardResult, gotScorecardResult); diff != "" { 78 | t.Fatalf("unexpected score:\n%s", diff) 79 | } 80 | } 81 | 82 | func TestSqliteCachePutGet_Expired(t *testing.T) { 83 | tmpDir := t.TempDir() 84 | 85 | cache, err := NewSqliteCache(tmpDir, WithDuration(1*time.Minute)) 86 | if err != nil { 87 | t.Fatalf("unexpected error creating cache: %s", err) 88 | } 89 | 90 | repository := "github.com/foo/bar" 91 | if err := cache.PutResult(context.Background(), repository, &models.ScorecardResult{ 92 | Score: 5.5, 93 | }); err != nil { 94 | t.Fatalf("unexpected error putting score in cache: %s", err) 95 | } 96 | 97 | // override the time.Now function so that GetResult believes it's 2 hours 98 | // in the future 99 | cache.(*sqliteCache).timeNow = func() time.Time { 100 | return time.Now().Add(2 * time.Hour) 101 | } 102 | 103 | if _, err := cache.GetResult(context.Background(), repository); !errors.Is(err, ErrNotFound) { 104 | t.Errorf("unexpected error; wanted %q but got %q", ErrNotFound, err) 105 | } 106 | } 107 | 108 | func TestSqliteCacheGet_NotFound(t *testing.T) { 109 | tmpDir := t.TempDir() 110 | 111 | cache, err := NewSqliteCache(tmpDir) 112 | if err != nil { 113 | t.Fatalf("unexpected error creating cache: %s", err) 114 | } 115 | 116 | if _, err := cache.GetResult(context.Background(), "github.com/foo/bar"); !errors.Is(err, ErrNotFound) { 117 | t.Errorf("unexpected error; wanted %q but got %q", ErrNotFound, err) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /internal/github-url/repository.go: -------------------------------------------------------------------------------- 1 | package github_url 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | "github.com/jetstack/tally/internal/types" 8 | ) 9 | 10 | var ( 11 | ghRegex = regexp.MustCompile(`(?:(?:https|git)(?:://|@))?github\.com[/:]([^/:#]+)/([^/#]*).*`) 12 | ghSuffixRegex = regexp.MustCompile(`(\.git/?)?(\.git|\?.*|#.*)?$`) 13 | ) 14 | 15 | // ToRepository parses a github url from a number of different formats into our 16 | // expected repository format: github.com//. 17 | func ToRepository(u string) *types.Repository { 18 | matches := ghRegex.FindStringSubmatch(ghSuffixRegex.ReplaceAllString(u, "")) 19 | if len(matches) < 3 { 20 | return nil 21 | } 22 | 23 | return &types.Repository{ 24 | Name: strings.Join([]string{"github.com", matches[1], matches[2]}, "/"), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/github-url/repository_test.go: -------------------------------------------------------------------------------- 1 | package github_url 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/jetstack/tally/internal/types" 8 | ) 9 | 10 | func TestToRepository(t *testing.T) { 11 | testCases := []struct { 12 | url string 13 | wantRepo *types.Repository 14 | }{ 15 | { 16 | url: "github.com/foo/bar", 17 | wantRepo: &types.Repository{ 18 | Name: "github.com/foo/bar", 19 | }, 20 | }, 21 | { 22 | url: "https://github.com/foo/bar", 23 | wantRepo: &types.Repository{ 24 | Name: "github.com/foo/bar", 25 | }, 26 | }, 27 | { 28 | url: "https://github.com/foo/bar/tree/main/baz", 29 | wantRepo: &types.Repository{ 30 | Name: "github.com/foo/bar", 31 | }, 32 | }, 33 | { 34 | url: "https://github.com/foo/bar#baz", 35 | wantRepo: &types.Repository{ 36 | Name: "github.com/foo/bar", 37 | }, 38 | }, 39 | { 40 | url: "https://github.com/foo/bar/", 41 | wantRepo: &types.Repository{ 42 | "github.com/foo/bar", 43 | }, 44 | }, 45 | { 46 | url: "https://github.com/foo/bar.git", 47 | wantRepo: &types.Repository{ 48 | Name: "github.com/foo/bar", 49 | }, 50 | }, 51 | { 52 | url: "https://github.com/foo/bar.git/", 53 | wantRepo: &types.Repository{ 54 | Name: "github.com/foo/bar", 55 | }, 56 | }, 57 | { 58 | url: "https://github.com/foo/bar.git?ref=baz", 59 | wantRepo: &types.Repository{ 60 | Name: "github.com/foo/bar", 61 | }, 62 | }, 63 | { 64 | url: "https://github.com/foo/bar.git?ref=baz", 65 | wantRepo: &types.Repository{ 66 | Name: "github.com/foo/bar", 67 | }, 68 | }, 69 | { 70 | url: "https://github.com/foo/bar?ref=something", 71 | wantRepo: &types.Repository{ 72 | Name: "github.com/foo/bar", 73 | }, 74 | }, 75 | { 76 | url: "https://github.com/foo/bar#something", 77 | wantRepo: &types.Repository{ 78 | Name: "github.com/foo/bar", 79 | }, 80 | }, 81 | { 82 | url: "git://github.com/foo/bar", 83 | wantRepo: &types.Repository{ 84 | Name: "github.com/foo/bar", 85 | }, 86 | }, 87 | { 88 | url: "git://github.com/foo/bar", 89 | wantRepo: &types.Repository{ 90 | Name: "github.com/foo/bar", 91 | }, 92 | }, 93 | { 94 | url: "git://github.com/foo/bar/", 95 | wantRepo: &types.Repository{ 96 | "github.com/foo/bar", 97 | }, 98 | }, 99 | { 100 | url: "git://github.com/foo/bar.git", 101 | wantRepo: &types.Repository{ 102 | "github.com/foo/bar", 103 | }, 104 | }, 105 | { 106 | url: "git://github.com/foo/bar.git/", 107 | wantRepo: &types.Repository{ 108 | Name: "github.com/foo/bar", 109 | }, 110 | }, 111 | { 112 | url: "git://github.com/foo/bar.git?ref=baz", 113 | wantRepo: &types.Repository{ 114 | Name: "github.com/foo/bar", 115 | }, 116 | }, 117 | { 118 | url: "git://github.com/foo/bar?ref=something", 119 | wantRepo: &types.Repository{ 120 | Name: "github.com/foo/bar", 121 | }, 122 | }, 123 | { 124 | url: "git://github.com/foo/bar#something", 125 | wantRepo: &types.Repository{ 126 | Name: "github.com/foo/bar", 127 | }, 128 | }, 129 | { 130 | url: "git@github.com:foo/bar.git", 131 | wantRepo: &types.Repository{ 132 | Name: "github.com/foo/bar", 133 | }, 134 | }, 135 | { 136 | url: "git@github.com:foo/bar.git", 137 | wantRepo: &types.Repository{ 138 | Name: "github.com/foo/bar", 139 | }, 140 | }, 141 | { 142 | url: "git@github.com:foo/bar.git/", 143 | wantRepo: &types.Repository{ 144 | Name: "github.com/foo/bar", 145 | }, 146 | }, 147 | { 148 | url: "git@github.com:foo/bar.git?ref=something", 149 | wantRepo: &types.Repository{ 150 | Name: "github.com/foo/bar", 151 | }, 152 | }, 153 | { 154 | url: "git@github.com:foo/bar.git#something", 155 | wantRepo: &types.Repository{ 156 | Name: "github.com/foo/bar", 157 | }, 158 | }, 159 | { 160 | url: "https://github.com/foo", 161 | }, 162 | { 163 | url: "https://gitlab.com/foo/bar", 164 | }, 165 | 166 | { 167 | url: "git://gitlab.com/foo/bar.git", 168 | }, 169 | { 170 | url: "git@gitlab.com:foo/bar.git", 171 | }, 172 | { 173 | url: "github.com", 174 | }, 175 | } 176 | for _, tc := range testCases { 177 | gotRepo := ToRepository(tc.url) 178 | 179 | if diff := cmp.Diff(gotRepo, tc.wantRepo); diff != "" { 180 | t.Errorf("unexpected repository:\n%s", diff) 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /internal/output/output.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "sort" 9 | "text/tabwriter" 10 | 11 | "github.com/jetstack/tally/internal/types" 12 | ) 13 | 14 | // ErrUnsupportedOutputFormat is returned when an output is requested by string that 15 | // this package doesn't implement. 16 | var ErrUnsupportedFormat = errors.New("unsupported output") 17 | 18 | // Format is a supported output format 19 | type Format string 20 | 21 | const ( 22 | // FormatShort prints the repositories and their scores. 23 | FormatShort Format = "short" 24 | 25 | // FormatWide prints the package version information, as well as 26 | // the repositories and their scores. 27 | FormatWide Format = "wide" 28 | 29 | // FormatJSON prints the report as a JSON document 30 | FormatJSON Format = "json" 31 | ) 32 | 33 | // Formats are the supported output formats 34 | var Formats = []Format{ 35 | FormatShort, 36 | FormatWide, 37 | FormatJSON, 38 | } 39 | 40 | // Option is a functional option that configures the output behaviour 41 | type Option func(*output) 42 | 43 | // WithAll is a functional option that configures outputs to return all 44 | // packages, even if they don't have a scorecard score 45 | func WithAll(all bool) Option { 46 | return func(o *output) { 47 | o.all = all 48 | } 49 | } 50 | 51 | // Output writes output for tally 52 | type Output interface { 53 | WriteReport(io.Writer, types.Report) error 54 | } 55 | 56 | // NewOutput returns a new output, configured by the provided options 57 | func NewOutput(format Format, opts ...Option) (Output, error) { 58 | o := &output{} 59 | for _, opt := range opts { 60 | opt(o) 61 | } 62 | 63 | switch format { 64 | case FormatShort: 65 | o.writer = o.writeShort 66 | case FormatWide: 67 | o.writer = o.writeWide 68 | case FormatJSON: 69 | o.writer = o.writeJSON 70 | default: 71 | return nil, fmt.Errorf("%s: %w", format, ErrUnsupportedFormat) 72 | } 73 | 74 | return o, nil 75 | } 76 | 77 | type output struct { 78 | all bool 79 | writer func(io.Writer, types.Report) error 80 | } 81 | 82 | // WriteReport writes the report to the given io.Writer in the 83 | // configured output format 84 | func (o *output) WriteReport(w io.Writer, report types.Report) error { 85 | // Sort the results by score 86 | sort.Slice(report.Results, func(i, j int) bool { 87 | var ( 88 | is float64 89 | js float64 90 | ) 91 | if report.Results[i].Result != nil { 92 | is = report.Results[i].Result.Score 93 | } 94 | 95 | if report.Results[j].Result != nil { 96 | js = report.Results[j].Result.Score 97 | } 98 | 99 | // If the scores are equal, then sort by repository.name 100 | if is == js { 101 | return report.Results[i].Repository.Name > report.Results[j].Repository.Name 102 | } 103 | 104 | return is > js 105 | }) 106 | 107 | return o.writer(w, report) 108 | } 109 | 110 | func (o *output) writeShort(w io.Writer, report types.Report) error { 111 | tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0) 112 | defer tw.Flush() 113 | fmt.Fprintf(tw, "REPOSITORY\tSCORE\n") 114 | 115 | printed := map[string]struct{}{} 116 | for _, result := range report.Results { 117 | if result.Repository.Name == "" { 118 | continue 119 | } 120 | if _, ok := printed[result.Repository.Name]; ok { 121 | continue 122 | } 123 | if result.Result != nil { 124 | fmt.Fprintf(tw, "%s\t%.1f\n", result.Repository.Name, result.Result.Score) 125 | } else if o.all { 126 | fmt.Fprintf(tw, "%s\t%s\n", result.Repository.Name, " ") 127 | } 128 | printed[result.Repository.Name] = struct{}{} 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (o *output) writeWide(w io.Writer, report types.Report) error { 135 | tw := tabwriter.NewWriter(w, 0, 0, 1, ' ', 0) 136 | defer tw.Flush() 137 | fmt.Fprintf(tw, "TYPE\tPACKAGE\tREPOSITORY\tSCORE\n") 138 | 139 | for _, result := range report.Results { 140 | for _, pkg := range result.Packages { 141 | if result.Result != nil { 142 | fmt.Fprintf(tw, "%s\t%s\t%s\t%.1f\n", pkg.Type, pkg.Name, result.Repository.Name, result.Result.Score) 143 | } else if o.all { 144 | fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", pkg.Type, pkg.Name, result.Repository.Name, " ") 145 | } 146 | } 147 | } 148 | 149 | return nil 150 | } 151 | 152 | func (o *output) writeJSON(w io.Writer, report types.Report) error { 153 | data, err := json.Marshal(report) 154 | if err != nil { 155 | return nil 156 | } 157 | 158 | if _, err := w.Write(data); err != nil { 159 | return err 160 | } 161 | 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /internal/scorecard/api/client.go: -------------------------------------------------------------------------------- 1 | package scorecard 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/jetstack/tally/internal/scorecard" 15 | "github.com/ossf/scorecard-webapp/app/generated/models" 16 | ) 17 | 18 | const ( 19 | // DefaultURL is the default base url for the scorecard API 20 | DefaultURL = "https://api.securityscorecards.dev" 21 | 22 | // DefaultTimeout is the default timeout for HTTP requests 23 | DefaultTimeout = time.Second * 30 24 | ) 25 | 26 | // ClientName is the name of the client 27 | const ClientName = "api" 28 | 29 | // Client retrieves scores from the scorecard API 30 | type Client struct { 31 | baseURL *url.URL 32 | httpClient *http.Client 33 | githubBaseURL string 34 | } 35 | 36 | // NewClient returns a client that fetches scores from the scorecard API 37 | func NewClient(rawURL string, opts ...Option) (scorecard.Client, error) { 38 | if rawURL == "" { 39 | rawURL = DefaultURL 40 | } 41 | baseURL, err := url.Parse(rawURL) 42 | if err != nil { 43 | return nil, fmt.Errorf("parsing url: %w", err) 44 | } 45 | c := &Client{ 46 | baseURL: baseURL, 47 | httpClient: &http.Client{ 48 | Timeout: DefaultTimeout, 49 | }, 50 | } 51 | for _, opt := range opts { 52 | opt(c) 53 | } 54 | 55 | return c, nil 56 | } 57 | 58 | // Name returns the client name 59 | func (c *Client) Name() string { 60 | return ClientName 61 | } 62 | 63 | // GetResult fetches a scorecard result from the public scorecard API 64 | func (c *Client) GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) { 65 | parts := strings.Split(repository, "/") 66 | if len(parts) != 3 { 67 | return nil, fmt.Errorf("unexpected number of parts in %s; wanted 3 but got %d: %w", repository, len(parts), scorecard.ErrInvalidRepository) 68 | } 69 | 70 | switch parts[0] { 71 | // Ensure the repository is public before checking for a score. We want to 72 | // avoid exposing private repositories to the API, as some users may 73 | // consider that sensitive information they may not want shipping off to 74 | // the Scorecard project. 75 | case "github.com": 76 | baseURL := c.githubBaseURL 77 | if baseURL == "" { 78 | baseURL = fmt.Sprintf("https://%s", parts[0]) 79 | } 80 | resp, err := c.httpClient.Head(fmt.Sprintf("%s/%s/%s", baseURL, parts[1], parts[2])) 81 | if err != nil { 82 | return nil, fmt.Errorf("error checking if repository is public: %w", errors.Join(scorecard.ErrUnexpectedResponse, err)) 83 | } 84 | if resp.StatusCode == http.StatusNotFound { 85 | return nil, fmt.Errorf("github repository not found: %w", scorecard.ErrNotFound) 86 | } 87 | if resp.StatusCode != http.StatusOK { 88 | return nil, fmt.Errorf("non-200 response from github when checking repository: %w", scorecard.ErrUnexpectedResponse) 89 | } 90 | default: 91 | return nil, fmt.Errorf("unsupported repository platform %s: %w", parts[0], scorecard.ErrInvalidRepository) 92 | } 93 | 94 | // Get result from the Scorecard API 95 | result, err := c.getResult(parts[0], parts[1], parts[2]) 96 | if err != nil { 97 | return nil, fmt.Errorf("fetching result: %w", err) 98 | } 99 | 100 | return result, nil 101 | } 102 | 103 | func (c *Client) getResult(platform, org, repo string) (*models.ScorecardResult, error) { 104 | uri, err := c.baseURL.Parse(fmt.Sprintf("/projects/%s/%s/%s", platform, org, repo)) 105 | if err != nil { 106 | return nil, fmt.Errorf("parsing path: %w", err) 107 | } 108 | resp, err := c.httpClient.Get(uri.String()) 109 | if err != nil { 110 | return nil, fmt.Errorf("%s: %w", uri, errors.Join(err, scorecard.ErrUnexpectedResponse)) 111 | } 112 | defer resp.Body.Close() 113 | if resp.StatusCode == http.StatusNotFound { 114 | return nil, fmt.Errorf("%s: %d: %w", uri, resp.StatusCode, scorecard.ErrNotFound) 115 | } 116 | if resp.StatusCode != http.StatusOK { 117 | return nil, fmt.Errorf("%s: %d: %w", uri, resp.StatusCode, scorecard.ErrUnexpectedResponse) 118 | } 119 | body, err := ioutil.ReadAll(resp.Body) 120 | if err != nil { 121 | return nil, fmt.Errorf("reading body from %s: %w", uri, err) 122 | } 123 | result := &models.ScorecardResult{} 124 | if err := json.Unmarshal(body, result); err != nil { 125 | return nil, fmt.Errorf("unmarshaling json from %s: %w", uri, err) 126 | } 127 | 128 | return result, nil 129 | } 130 | -------------------------------------------------------------------------------- /internal/scorecard/api/client_test.go: -------------------------------------------------------------------------------- 1 | package scorecard 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/jetstack/tally/internal/scorecard" 15 | "github.com/ossf/scorecard-webapp/app/generated/models" 16 | ) 17 | 18 | func TestClientGetScore(t *testing.T) { 19 | type testCase struct { 20 | scorecardHandler func(w http.ResponseWriter, r *http.Request) 21 | githubHandler func(w http.ResponseWriter, r *http.Request) 22 | repository string 23 | wantErr error 24 | wantScorecardResult *models.ScorecardResult 25 | } 26 | testCases := map[string]func(t *testing.T) *testCase{ 27 | "should return expected score": func(t *testing.T) *testCase { 28 | platform := "github.com" 29 | org := "foo" 30 | repo := "bar" 31 | wantScorecardResult := &models.ScorecardResult{ 32 | Repo: &models.Repo{ 33 | Name: fmt.Sprintf("%s/%s/%s", platform, org, repo), 34 | }, 35 | Score: 6.5, 36 | Checks: []*models.ScorecardCheck{ 37 | { 38 | Name: "foo", 39 | Score: 7, 40 | }, 41 | { 42 | Name: "bar", 43 | Score: 5, 44 | }, 45 | }, 46 | } 47 | return &testCase{ 48 | repository: strings.Join([]string{platform, org, repo}, "/"), 49 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 50 | parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/projects/"), "/") 51 | if len(parts) != 3 { 52 | t.Fatalf("unexpected number of parts in request path; wanted 3 but got %d", len(parts)) 53 | } 54 | if parts[0] != "github.com" { 55 | t.Fatalf("unexpected platform; wanted %s but got %s", platform, parts[0]) 56 | } 57 | if parts[1] != org { 58 | t.Fatalf("unexpected platform; wanted %s but got %s", org, parts[1]) 59 | } 60 | if parts[2] != repo { 61 | t.Fatalf("unexpected platform; wanted %s but got %s", repo, parts[2]) 62 | } 63 | 64 | w.Header().Set("Content-type", "application/json") 65 | json.NewEncoder(w).Encode(wantScorecardResult) 66 | }, 67 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 68 | w.WriteHeader(http.StatusOK) 69 | }, 70 | wantScorecardResult: wantScorecardResult, 71 | } 72 | }, 73 | "should return scorecard.ErrNotFound when API returns a 404 response": func(t *testing.T) *testCase { 74 | return &testCase{ 75 | repository: "github.com/foo/bar", 76 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 77 | w.WriteHeader(http.StatusNotFound) 78 | }, 79 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 80 | w.WriteHeader(http.StatusOK) 81 | }, 82 | wantErr: scorecard.ErrNotFound, 83 | } 84 | }, 85 | "should return scorecard.ErrUnexpectedResponse when API returns a 500 response": func(t *testing.T) *testCase { 86 | return &testCase{ 87 | repository: "github.com/foo/bar", 88 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 89 | w.WriteHeader(http.StatusInternalServerError) 90 | }, 91 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 92 | w.WriteHeader(http.StatusOK) 93 | }, 94 | wantErr: scorecard.ErrUnexpectedResponse, 95 | } 96 | }, 97 | "should return scorecard.ErrInvalidRepository when an invalid repository string is provided": func(t *testing.T) *testCase { 98 | return &testCase{ 99 | repository: "github.com/foo/bar/main", 100 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 101 | t.Fatalf("unexpected call to scorecard API") 102 | }, 103 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 104 | t.Fatalf("unexpected call to github API") 105 | }, 106 | wantErr: scorecard.ErrInvalidRepository, 107 | } 108 | }, 109 | "should return scorecard.ErrInvalidRepository for a non-github repo": func(t *testing.T) *testCase { 110 | return &testCase{ 111 | repository: "gitlab.com/foo/bar", 112 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 113 | t.Fatalf("unexpected call to scorecard API") 114 | }, 115 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 116 | t.Fatalf("unexpected call to github API") 117 | }, 118 | wantErr: scorecard.ErrInvalidRepository, 119 | } 120 | }, 121 | "should return scorecard.ErrNotFound when Github returns a 404": func(t *testing.T) *testCase { 122 | return &testCase{ 123 | repository: "github.com/foo/bar", 124 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 125 | t.Fatalf("unexpected call to scorecard API") 126 | }, 127 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 128 | w.WriteHeader(http.StatusNotFound) 129 | }, 130 | wantErr: scorecard.ErrNotFound, 131 | } 132 | }, 133 | "should return scorecard.ErrUnexpectedResponse when Github returns a 500": func(t *testing.T) *testCase { 134 | return &testCase{ 135 | repository: "github.com/foo/bar", 136 | scorecardHandler: func(w http.ResponseWriter, r *http.Request) { 137 | t.Fatalf("unexpected call to scorecard API") 138 | }, 139 | githubHandler: func(w http.ResponseWriter, r *http.Request) { 140 | w.WriteHeader(http.StatusInternalServerError) 141 | }, 142 | wantErr: scorecard.ErrUnexpectedResponse, 143 | } 144 | }, 145 | } 146 | for n, setup := range testCases { 147 | t.Run(n, func(t *testing.T) { 148 | tc := setup(t) 149 | 150 | mux := http.NewServeMux() 151 | server := httptest.NewServer(mux) 152 | mux.HandleFunc("/projects/", tc.scorecardHandler) 153 | 154 | gMux := http.NewServeMux() 155 | gServer := httptest.NewServer(gMux) 156 | gMux.HandleFunc("/", tc.githubHandler) 157 | 158 | opt := func(c *Client) { 159 | c.githubBaseURL = gServer.URL 160 | } 161 | c, err := NewClient(server.URL, opt) 162 | if err != nil { 163 | t.Fatalf("unexpected error creating client: %s", err) 164 | } 165 | 166 | gotScorecardResult, err := c.GetResult(context.Background(), tc.repository) 167 | if !errors.Is(err, tc.wantErr) { 168 | t.Fatalf("unexpected error: %s", err) 169 | } 170 | if diff := cmp.Diff(tc.wantScorecardResult, gotScorecardResult); diff != "" { 171 | t.Errorf("unexpected score:\n%s", diff) 172 | } 173 | }) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /internal/scorecard/api/options.go: -------------------------------------------------------------------------------- 1 | package scorecard 2 | 3 | import "time" 4 | 5 | // Option is a functional option that configures the scorecard API client 6 | type Option func(c *Client) 7 | 8 | // WithTimeout is a functional option that configures the timeout duration for 9 | // HTTP requests 10 | func WithTimeout(timeout time.Duration) Option { 11 | return func(c *Client) { 12 | c.httpClient.Timeout = timeout 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /internal/scorecard/client.go: -------------------------------------------------------------------------------- 1 | package scorecard 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "os" 11 | 12 | "github.com/ossf/scorecard-webapp/app/generated/models" 13 | "github.com/ossf/scorecard/v4/checker" 14 | "github.com/ossf/scorecard/v4/checks" 15 | docs "github.com/ossf/scorecard/v4/docs/checks" 16 | "github.com/ossf/scorecard/v4/log" 17 | "github.com/ossf/scorecard/v4/pkg" 18 | "github.com/sirupsen/logrus" 19 | ) 20 | 21 | var ( 22 | // ErrNotFound is returned by the client when it can't find a score for the 23 | // repository 24 | ErrNotFound = errors.New("score not found") 25 | 26 | // ErrUnexpectedResponse is returned when a scorecard client gets an unexpected 27 | // response from its upstream source 28 | ErrUnexpectedResponse = errors.New("unexpected response") 29 | 30 | // ErrInvalidRepository is returned when an invalid repository is 31 | // provided as input 32 | ErrInvalidRepository = errors.New("invalid repository") 33 | ) 34 | 35 | // Client fetches scorecard results for repositories 36 | type Client interface { 37 | // GetResult retrieves a scorecard result for the given platform, org 38 | // and repo 39 | GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) 40 | 41 | // Name returns the name of this client 42 | Name() string 43 | } 44 | 45 | // ScorecardClientName is the name of the scorecard client 46 | const ScorecardClientName = "scorecard" 47 | 48 | // ScorecardClient generates scorecard scores for repositories 49 | type ScorecardClient struct{} 50 | 51 | // NewScorecardClient returns a new client that generates scores itself for 52 | // repositories 53 | func NewScorecardClient() (Client, error) { 54 | if os.Getenv("GITHUB_TOKEN") == "" { 55 | return nil, fmt.Errorf("GITHUB_TOKEN environment variable must be set when using the scorecard client to generate scores") 56 | } 57 | return &ScorecardClient{}, nil 58 | } 59 | 60 | // Name is the name of the client 61 | func (c *ScorecardClient) Name() string { 62 | return ScorecardClientName 63 | } 64 | 65 | // GetResult generates a scorecard result with the scorecard client 66 | func (c *ScorecardClient) GetResult(ctx context.Context, repository string) (*models.ScorecardResult, error) { 67 | // Scorecard requires a logger but we want to suppress its output 68 | logger := logrus.New() 69 | logger.Out = ioutil.Discard 70 | 71 | repoURI, repoClient, ossFuzzRepoClient, ciiClient, vulnsClient, err := checker.GetClients(ctx, repository, "", log.NewLogrusLogger(logger)) 72 | if err != nil { 73 | return nil, fmt.Errorf("getting clients: %w", errors.Join(ErrNotFound, err)) 74 | } 75 | defer repoClient.Close() 76 | if ossFuzzRepoClient != nil { 77 | defer ossFuzzRepoClient.Close() 78 | } 79 | 80 | enabledChecks := checks.GetAll() 81 | 82 | checkDocs, err := docs.Read() 83 | if err != nil { 84 | return nil, fmt.Errorf("checking docs: %s", errors.Join(ErrNotFound, err)) 85 | } 86 | 87 | res, err := pkg.RunScorecard( 88 | ctx, 89 | repoURI, 90 | "HEAD", 91 | 0, 92 | enabledChecks, 93 | repoClient, 94 | ossFuzzRepoClient, 95 | ciiClient, 96 | vulnsClient, 97 | ) 98 | if err != nil { 99 | return nil, fmt.Errorf("running scorecards: %w", errors.Join(ErrNotFound, err)) 100 | } 101 | 102 | var buf bytes.Buffer 103 | if err := res.AsJSON2(true, log.FatalLevel, checkDocs, &buf); err != nil { 104 | return nil, fmt.Errorf("formatting results as JSON v2: %w", err) 105 | } 106 | 107 | result := &models.ScorecardResult{} 108 | if err := json.Unmarshal(buf.Bytes(), result); err != nil { 109 | return nil, fmt.Errorf("unmarshaling result from json: %w", err) 110 | } 111 | 112 | return result, nil 113 | } 114 | -------------------------------------------------------------------------------- /internal/tally/run.go: -------------------------------------------------------------------------------- 1 | package tally 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "runtime" 9 | "sync" 10 | 11 | "github.com/cheggaaa/pb/v3" 12 | "github.com/jetstack/tally/internal/scorecard" 13 | "github.com/jetstack/tally/internal/types" 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | const pbTemplate = `{{ string . "message" }} {{ bar . "[" "-" ">" "." "]"}} {{counters . }}` 18 | 19 | // Run finds scorecard scores for the provided packages 20 | func Run(ctx context.Context, w io.Writer, clients []scorecard.Client, pkgRepos ...*types.PackageRepositories) (*types.Report, error) { 21 | // If the writer is nil then just discard anything we write 22 | if w == nil { 23 | w = io.Discard 24 | } 25 | 26 | // Map repositories to packages 27 | repoPkgs := map[string][]types.Package{} 28 | for _, pkgRepo := range pkgRepos { 29 | // We want to include packages without a repository in the 30 | // results 31 | repos := pkgRepo.Repositories 32 | if len(repos) == 0 { 33 | repos = []types.Repository{ 34 | { 35 | Name: "", 36 | }, 37 | } 38 | } 39 | for _, repo := range repos { 40 | repoPkgs[repo.Name] = append(repoPkgs[repo.Name], pkgRepo.Package) 41 | } 42 | } 43 | 44 | // Map into results 45 | var results []types.Result 46 | for repoName, pkgs := range repoPkgs { 47 | results = append(results, types.Result{ 48 | Repository: types.Repository{ 49 | Name: repoName, 50 | }, 51 | Packages: pkgs, 52 | }) 53 | } 54 | 55 | bar := pb.ProgressBarTemplate(pbTemplate).Start(len(results)) 56 | bar.SetWriter(w) 57 | bar.Set(pb.CleanOnFinish, true) 58 | defer bar.Finish() 59 | 60 | for _, client := range clients { 61 | var g errgroup.Group 62 | g.SetLimit(runtime.NumCPU()) 63 | mux := sync.RWMutex{} 64 | for i, result := range results { 65 | if result.Result != nil || result.Repository.Name == "" { 66 | continue 67 | } 68 | i, result := i, result 69 | g.Go(func() error { 70 | mux.Lock() 71 | // Tweak the message displayed in the progress bar 72 | // depending on the type of client 73 | switch client.Name() { 74 | case scorecard.ScorecardClientName: 75 | bar.Set("message", fmt.Sprintf("Generating score for %q", result.Repository.Name)) 76 | default: 77 | bar.Set("message", fmt.Sprintf("Finding score for %q", result.Repository.Name)) 78 | } 79 | mux.Unlock() 80 | 81 | scorecardResult, err := client.GetResult(ctx, result.Repository.Name) 82 | if err != nil && !errors.Is(err, scorecard.ErrNotFound) { 83 | return fmt.Errorf("getting score for %s: %w", result.Repository.Name, err) 84 | } 85 | if scorecardResult == nil { 86 | return nil 87 | } 88 | 89 | results[i].Result = scorecardResult 90 | 91 | mux.Lock() 92 | bar.Increment() 93 | mux.Unlock() 94 | 95 | return nil 96 | }) 97 | } 98 | if err := g.Wait(); err != nil { 99 | return nil, err 100 | } 101 | } 102 | 103 | bar.Set("message", "DONE") 104 | 105 | return &types.Report{ 106 | Results: results, 107 | }, nil 108 | } 109 | -------------------------------------------------------------------------------- /internal/types/package.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Package is a package 4 | type Package struct { 5 | Type string `json:"type"` 6 | Name string `json:"name"` 7 | } 8 | 9 | // Equals compares one package to another 10 | func (pkg *Package) Equals(p Package) bool { 11 | return pkg.Type == p.Type && pkg.Name == p.Name 12 | } 13 | -------------------------------------------------------------------------------- /internal/types/package_repositories.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // PackageRepositories represents the repositories associated with a package 4 | type PackageRepositories struct { 5 | Package 6 | Repositories []Repository `json:"repositories"` 7 | } 8 | 9 | // AddRepositories adds repositories. It will ignore any repositories that are 10 | // already associated with the package. 11 | func (pkg *PackageRepositories) AddRepositories(repos ...Repository) { 12 | for _, repo := range repos { 13 | if containsRepo(pkg.Repositories, repo) { 14 | continue 15 | } 16 | 17 | pkg.Repositories = append(pkg.Repositories, repo) 18 | } 19 | } 20 | 21 | func containsRepo(repos []Repository, repo Repository) bool { 22 | for _, r := range repos { 23 | if r.Name == repo.Name { 24 | return true 25 | } 26 | } 27 | 28 | return false 29 | } 30 | -------------------------------------------------------------------------------- /internal/types/report.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Report details tally's findings 4 | type Report struct { 5 | Results []Result `json:"results"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/types/repository.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // Repository is a source code repository 4 | type Repository struct { 5 | Name string `json:"name"` 6 | } 7 | -------------------------------------------------------------------------------- /internal/types/result.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "github.com/ossf/scorecard-webapp/app/generated/models" 4 | 5 | // Result is the scorecard result for a repository, with any associated packages 6 | type Result struct { 7 | Repository Repository `json:"repository,omitempty"` 8 | Packages []Package `json:"packages,omitempty"` 9 | Result *models.ScorecardResult `json:"result,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/jetstack/tally/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | --------------------------------------------------------------------------------