diff --git a/checker/raw_result.go b/checker/raw_result.go index ea99b4d80b95..81a12d048826 100644 --- a/checker/raw_result.go +++ b/checker/raw_result.go @@ -37,6 +37,7 @@ type RawResults struct { DependencyUpdateToolResults DependencyUpdateToolData FuzzingResults FuzzingData LicenseResults LicenseData + SBOMResults SBOMData MaintainedResults MaintainedData Metadata MetadataData PackagingResults PackagingData @@ -168,6 +169,18 @@ type LicenseData struct { LicenseFiles []LicenseFile } +// SBOM details. +type SBOM struct { + Name string // SBOM Filename + File File // SBOM File Object +} + +// SBOMData contains the raw results for the SBOM check. +// Some repos may have more than one SBOM. +type SBOMData struct { + SBOMFiles []SBOM +} + // CodeReviewData contains the raw results // for the Code-Review check. type CodeReviewData struct { diff --git a/checks/all_checks.go b/checks/all_checks.go index 90de4f7c743a..a67298be27bb 100644 --- a/checks/all_checks.go +++ b/checks/all_checks.go @@ -38,6 +38,7 @@ func getAll(overrideExperimental bool) checker.CheckNameToFnMap { if _, experimental := os.LookupEnv("SCORECARD_EXPERIMENTAL"); !experimental { // TODO: remove this check when v6 is released delete(possibleChecks, CheckWebHooks) + delete(possibleChecks, CheckSBOM) } return possibleChecks diff --git a/checks/evaluation/sbom.go b/checks/evaluation/sbom.go new file mode 100644 index 000000000000..ef040c9b219c --- /dev/null +++ b/checks/evaluation/sbom.go @@ -0,0 +1,75 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package evaluation + +import ( + "github.com/ossf/scorecard/v5/checker" + sce "github.com/ossf/scorecard/v5/errors" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/hasReleaseSBOM" + "github.com/ossf/scorecard/v5/probes/hasSBOM" +) + +// SBOM applies the score policy for the SBOM check. +func SBOM(name string, + findings []finding.Finding, + dl checker.DetailLogger, +) checker.CheckResult { + // We have 4 unique probes, each should have a finding. + expectedProbes := []string{ + hasSBOM.Probe, + hasReleaseSBOM.Probe, + } + + if !finding.UniqueProbesEqual(findings, expectedProbes) { + e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results") + return checker.CreateRuntimeErrorResult(name, e) + } + + // Compute the score. + score := 0 + m := make(map[string]bool) + var logLevel checker.DetailType + for i := range findings { + f := &findings[i] + switch f.Outcome { + case finding.OutcomeTrue: + logLevel = checker.DetailInfo + switch f.Probe { + case hasSBOM.Probe: + score += scoreProbeOnce(f.Probe, m, 5) + case hasReleaseSBOM.Probe: + score += scoreProbeOnce(f.Probe, m, 5) + } + case finding.OutcomeFalse: + logLevel = checker.DetailWarn + default: + continue // for linting + } + checker.LogFinding(dl, f, logLevel) + } + + _, defined := m[hasSBOM.Probe] + if !defined { + return checker.CreateMinScoreResult(name, "SBOM file not detected") + } + + _, defined = m[hasReleaseSBOM.Probe] + if defined { + return checker.CreateMaxScoreResult(name, "SBOM file found in release artifacts") + } + + return checker.CreateResultWithScore(name, "SBOM file found in project", score) +} diff --git a/checks/evaluation/sbom_test.go b/checks/evaluation/sbom_test.go new file mode 100644 index 000000000000..1ddaab6e42f1 --- /dev/null +++ b/checks/evaluation/sbom_test.go @@ -0,0 +1,95 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package evaluation + +import ( + "testing" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + scut "github.com/ossf/scorecard/v5/utests" +) + +func TestSBOM(t *testing.T) { + t.Parallel() + tests := []struct { + name string + findings []finding.Finding + result scut.TestReturn + }{ + { + name: "No SBOM. Min Score", + findings: []finding.Finding{ + { + Probe: "hasSBOM", + Outcome: finding.OutcomeFalse, + }, + { + Probe: "hasReleaseSBOM", + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: checker.MinResultScore, + NumberOfInfo: 0, + NumberOfWarn: 2, + }, + }, + { + name: "Only Source SBOM. Half Points", + findings: []finding.Finding{ + { + Probe: "hasSBOM", + Outcome: finding.OutcomeTrue, + }, + { + Probe: "hasReleaseSBOM", + Outcome: finding.OutcomeFalse, + }, + }, + result: scut.TestReturn{ + Score: 5, + NumberOfInfo: 1, + NumberOfWarn: 1, + }, + }, + { + name: "SBOM in Release Assets. Max score", + findings: []finding.Finding{ + { + Probe: "hasSBOM", + Outcome: finding.OutcomeTrue, + }, + { + Probe: "hasReleaseSBOM", + Outcome: finding.OutcomeTrue, + }, + }, + result: scut.TestReturn{ + Score: checker.MaxResultScore, + NumberOfInfo: 2, + NumberOfWarn: 0, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dl := scut.TestDetailLogger{} + got := SBOM(tt.name, tt.findings, &dl) + scut.ValidateTestReturn(t, tt.name, &tt.result, &got, &dl) + }) + } +} diff --git a/checks/raw/sbom.go b/checks/raw/sbom.go new file mode 100644 index 000000000000..54bfc621641e --- /dev/null +++ b/checks/raw/sbom.go @@ -0,0 +1,106 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package raw + +import ( + "fmt" + "regexp" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + "github.com/ossf/scorecard/v5/finding" +) + +var ( + reRootFile = regexp.MustCompile(`^[^.]([^//]*)$`) + reSBOMFile = regexp.MustCompile( + `(?i).+\.(cdx.json|cdx.xml|spdx|spdx.json|spdx.xml|spdx.y[a?]ml|spdx.rdf|spdx.rdf.xml)`, + ) +) + +const releaseLookBack = 5 + +// SBOM retrieves the raw data for the SBOM check. +func SBOM(c *checker.CheckRequest) (checker.SBOMData, error) { + var results checker.SBOMData + + releases, lerr := c.RepoClient.ListReleases() + if lerr != nil { + return results, fmt.Errorf("RepoClient.ListReleases: %w", lerr) + } + + results.SBOMFiles = append(results.SBOMFiles, checkSBOMReleases(releases)...) + + // Look for SBOMs in source + repoFiles, err := c.RepoClient.ListFiles(func(file string) (bool, error) { + return reSBOMFile.MatchString(file) && reRootFile.MatchString(file), nil + }) + if err != nil { + return results, fmt.Errorf("error during ListFiles: %w", err) + } + + results.SBOMFiles = append(results.SBOMFiles, checkSBOMSource(repoFiles)...) + + return results, nil +} + +func checkSBOMReleases(releases []clients.Release) []checker.SBOM { + var foundSBOMs []checker.SBOM + + for i := range releases { + if i >= releaseLookBack { + break + } + + v := releases[i] + + for _, link := range v.Assets { + if !reSBOMFile.MatchString(link.Name) { + continue + } + + foundSBOMs = append(foundSBOMs, + checker.SBOM{ + File: checker.File{ + Path: link.URL, + Type: finding.FileTypeURL, + }, + Name: link.Name, + }) + + // Only want one sbom from each release + break + } + } + return foundSBOMs +} + +func checkSBOMSource(fileList []string) []checker.SBOM { + var foundSBOMs []checker.SBOM + + for _, file := range fileList { + // TODO: parse matching file contents to determine schema & version + foundSBOMs = append(foundSBOMs, + checker.SBOM{ + File: checker.File{ + Path: file, + Type: finding.FileTypeSource, + }, + Name: file, + }) + } + + return foundSBOMs +} diff --git a/checks/raw/sbom_test.go b/checks/raw/sbom_test.go new file mode 100644 index 000000000000..52e393479488 --- /dev/null +++ b/checks/raw/sbom_test.go @@ -0,0 +1,129 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package raw + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/google/go-cmp/cmp" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + mockrepo "github.com/ossf/scorecard/v5/clients/mockclients" + "github.com/ossf/scorecard/v5/finding" + scut "github.com/ossf/scorecard/v5/utests" +) + +func TestSbom(t *testing.T) { + t.Parallel() + tests := []struct { + name string + releases []clients.Release + files []string + err error + expected checker.SBOMData + }{ + { + name: "With Sbom in release artifacts", + releases: []clients.Release{ + { + Assets: []clients.ReleaseAsset{ + { + Name: "test-sbom.cdx.json", + URL: "https://this.url", + }, + }, + }, + }, + files: []string{}, + expected: checker.SBOMData{ + SBOMFiles: []checker.SBOM{ + { + Name: "test-sbom.cdx.json", + File: checker.File{ + Type: finding.FileTypeURL, + Path: "https://this.url", + }, + }, + }, + }, + err: nil, + }, + { + name: "With Sbom in source", + releases: []clients.Release{}, + files: []string{"test-sbom.spdx.json"}, + err: nil, + expected: checker.SBOMData{ + SBOMFiles: []checker.SBOM{ + { + Name: "test-sbom.spdx.json", + File: checker.File{ + Type: finding.FileTypeSource, + Path: "test-sbom.spdx.json", + }, + }, + }, + }, + }, + { + name: "Without SBOM", + releases: []clients.Release{}, + files: []string{}, + expected: checker.SBOMData{}, + err: nil, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + mockRepo := mockrepo.NewMockRepoClient(ctrl) + + mockRepo.EXPECT().ListReleases().DoAndReturn( + func() ([]clients.Release, error) { + if tt.err != nil { + return nil, tt.err + } + return tt.releases, tt.err + }, + ).MaxTimes(1) + + mockRepo.EXPECT().ListFiles(gomock.Any()).DoAndReturn(func(predicate func(string) (bool, error)) ([]string, error) { + return tt.files, nil + }).AnyTimes() + + dl := scut.TestDetailLogger{} + req := checker.CheckRequest{ + RepoClient: mockRepo, + Ctx: context.Background(), + Dlogger: &dl, + } + res, err := SBOM(&req) + if tt.err != nil { + if err == nil { + t.Fatalf("Expected error %v, got nil", tt.err) + } + } + + if !cmp.Equal(res, tt.expected) { + t.Errorf("Expected %v, got %v for %v", tt.expected, res, tt.name) + } + }) + } +} diff --git a/checks/sbom.go b/checks/sbom.go new file mode 100644 index 000000000000..5c37e53ca663 --- /dev/null +++ b/checks/sbom.go @@ -0,0 +1,71 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checks + +import ( + "os" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/checks/evaluation" + "github.com/ossf/scorecard/v5/checks/raw" + sce "github.com/ossf/scorecard/v5/errors" + "github.com/ossf/scorecard/v5/probes" + "github.com/ossf/scorecard/v5/probes/zrunner" +) + +// SBOM is the registered name for SBOM. +const CheckSBOM = "SBOM" + +//nolint:gochecknoinits +func init() { + if err := registerCheck(CheckSBOM, SBOM, nil); err != nil { + // this should never happen + panic(err) + } +} + +// SBOM runs SBOM check. +func SBOM(c *checker.CheckRequest) checker.CheckResult { + _, enabled := os.LookupEnv("SCORECARD_EXPERIMENTAL") + if !enabled { + c.Dlogger.Warn(&checker.LogMessage{ + Text: "SCORECARD_EXPERIMENTAL is not set, not running the SBOM check", + }) + + e := sce.WithMessage(sce.ErrUnsupportedCheck, "SCORECARD_EXPERIMENTAL is not set, not running the SBOM check") + return checker.CreateRuntimeErrorResult(CheckSBOM, e) + } + + rawData, err := raw.SBOM(c) + if err != nil { + e := sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + return checker.CreateRuntimeErrorResult(CheckSBOM, e) + } + + // Set the raw results. + pRawResults := getRawResults(c) + pRawResults.SBOMResults = rawData + + // Evaluate the probes. + findings, err := zrunner.Run(pRawResults, probes.SBOM) + if err != nil { + e := sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + return checker.CreateRuntimeErrorResult(CheckSBOM, e) + } + + ret := evaluation.SBOM(CheckSBOM, findings, c.Dlogger) + ret.Findings = findings + return ret +} diff --git a/checks/sbom_test.go b/checks/sbom_test.go new file mode 100644 index 000000000000..663791d47af6 --- /dev/null +++ b/checks/sbom_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package checks + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/clients" + mockrepo "github.com/ossf/scorecard/v5/clients/mockclients" + scut "github.com/ossf/scorecard/v5/utests" +) + +func TestSbom(t *testing.T) { + tests := []struct { + name string + releases []clients.Release + files []string + err error + expected scut.TestReturn + }{ + { + name: "With Sbom in release artifacts", + releases: []clients.Release{ + { + Assets: []clients.ReleaseAsset{ + { + Name: "test-sbom.cdx.json", + URL: "https://this.url", + }, + }, + }, + }, + files: []string{}, + expected: scut.TestReturn{ + Score: checker.MaxResultScore, + NumberOfInfo: 2, + NumberOfWarn: 0, + }, + err: nil, + }, + { + name: "With Sbom in source", + releases: []clients.Release{}, + files: []string{"test-sbom.spdx.json"}, + err: nil, + expected: scut.TestReturn{ + Score: 5, + NumberOfInfo: 1, + NumberOfWarn: 1, + }, + }, + { + name: "Without SBOM", + releases: []clients.Release{}, + files: []string{}, + expected: scut.TestReturn{ + Score: checker.MinResultScore, + NumberOfInfo: 0, + NumberOfWarn: 2, + }, + err: nil, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Setenv("SCORECARD_EXPERIMENTAL", "true") + ctrl := gomock.NewController(t) + mockRepo := mockrepo.NewMockRepoClient(ctrl) + + mockRepo.EXPECT().ListReleases().DoAndReturn( + func() ([]clients.Release, error) { + if tt.err != nil { + return nil, tt.err + } + return tt.releases, tt.err + }, + ).MaxTimes(1) + + mockRepo.EXPECT().ListFiles(gomock.Any()).DoAndReturn(func(predicate func(string) (bool, error)) ([]string, error) { + return tt.files, nil + }).AnyTimes() + + dl := scut.TestDetailLogger{} + req := checker.CheckRequest{ + RepoClient: mockRepo, + Ctx: context.Background(), + Dlogger: &dl, + } + res := SBOM(&req) + if tt.err != nil { + if res.Error == nil { + t.Fatalf("Expected error %v, got nil", tt.err) + } + } + + scut.ValidateTestReturn(t, tt.name, &tt.expected, &res, &dl) + }) + } +} diff --git a/clients/githubrepo/client.go b/clients/githubrepo/client.go index 293a0cc61aae..cfb590d38faa 100644 --- a/clients/githubrepo/client.go +++ b/clients/githubrepo/client.go @@ -126,6 +126,7 @@ func (client *Client) InitRepo(inputRepo clients.Repo, commitSHA string, commitD // Setup licensesHandler. client.licenses.init(client.ctx, client.repourl) + return nil } diff --git a/docs/checks.md b/docs/checks.md index a0e069e2324d..d3589c0b260b 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -541,6 +541,38 @@ is therefore not a definitive indication that the project is at risk. **Remediation steps** - Run CodeQL checks in your CI/CD by following the instructions [here](https://github.com/github/codeql-action#usage). +## SBOM + +Risk: `Medium` (possible inaccurate reporting of dependencies/vulnerabilities) + +This check tries to determine if the project maintains a Software Bill of Materials (SBOM) +either as a file in the source or a release artifact. + +An SBOM can give users information about what dependencies your project has which +allows them to identify vulnerabilities in the software supply chain. + +Standards to be used during checks; +- OSSF SBOM Everywhere SIG naming and directory conventions: + - + +This check currently looks for the existence of an SBOM in the +source of a project and as a pipeline or release artifact. + +An SBOM Exists (one or more) (5/10 points): + - Any SBOM found counts for this test either in source. pipeline or release. + - A SBOM stored with your source code is not ideal, but is a good first step. + \* It is recommended to publish with your release artifacts. + +An SBOM is published as a release artifact (5/10 points): + - This is the preferred way to store an SBOM, and will be awarded full points. + - Checks release artifacts for an SBOM file matching established standards + + +**Remediation steps** +- For Gitlab, see more information [here](https://docs.gitlab.com/ee/user/application_security/dependency_scanning/index.html#cyclonedx-software-bill-of-materials). +- For GitHub, see more information [here](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security). +- Alternatively, there are other tools available to generate [CycloneDX](https://cyclonedx.org/tool-center/) and [SPDX](https://spdx.dev/use/tools/) SBOMs. + ## Security-Policy Risk: `Medium` (possible insecure reporting of vulnerabilities) diff --git a/docs/checks/internal/checks.yaml b/docs/checks/internal/checks.yaml index 9abc6802624e..e48dd121630c 100644 --- a/docs/checks/internal/checks.yaml +++ b/docs/checks/internal/checks.yaml @@ -558,6 +558,46 @@ checks: - >- Run CodeQL checks in your CI/CD by following the instructions [here](https://github.com/github/codeql-action#usage). + + SBOM: + risk: Medium + short: Determines if the project maintains a Software Bill of Materials. + repos: GitHub, Gitlab + tags: supply-chain, security, vulnerabilities, dependencies, SBOM + description: | + Risk: `Medium` (possible inaccurate reporting of dependencies/vulnerabilities) + + This check tries to determine if the project maintains a Software Bill of Materials (SBOM) + either as a file in the source or a release artifact. + + An SBOM can give users information about what dependencies your project has which + allows them to identify vulnerabilities in the software supply chain. + + Standards to be used during checks; + - OSSF SBOM Everywhere SIG naming and directory conventions: + - + + This check currently looks for the existence of an SBOM in the + source of a project and as a pipeline or release artifact. + + An SBOM Exists (one or more) (5/10 points): + - Any SBOM found counts for this test either in source. pipeline or release. + - A SBOM stored with your source code is not ideal, but is a good first step. + \* It is recommended to publish with your release artifacts. + + An SBOM is published as a release artifact (5/10 points): + - This is the preferred way to store an SBOM, and will be awarded full points. + - Checks release artifacts for an SBOM file matching established standards + remediation: + - >- + For Gitlab, see more information + [here](https://docs.gitlab.com/ee/user/application_security/dependency_scanning/index.html#cyclonedx-software-bill-of-materials). + - >- + For GitHub, see more information + [here](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security). + - >- + Alternatively, there are other tools available to generate [CycloneDX](https://cyclonedx.org/tool-center/) and [SPDX](https://spdx.dev/use/tools/) SBOMs. + Security-Policy: risk: Medium short: Determines if the project has published a security policy. diff --git a/internal/probes/probes.go b/internal/probes/probes.go index a5282ba219a0..bc125256d907 100644 --- a/internal/probes/probes.go +++ b/internal/probes/probes.go @@ -40,6 +40,7 @@ const ( Packaging CheckName = "Packaging" PinnedDependencies CheckName = "Pinned-Dependencies" SAST CheckName = "SAST" + SBOM CheckName = "SBOM" SecurityPolicy CheckName = "Security-Policy" SignedReleases CheckName = "Signed-Releases" TokenPermissions CheckName = "Token-Permissions" diff --git a/pkg/scorecard_result.go b/pkg/scorecard_result.go index 4a589db51353..f2ae996c65e4 100644 --- a/pkg/scorecard_result.go +++ b/pkg/scorecard_result.go @@ -345,6 +345,12 @@ func assignRawData(probeCheckName string, request *checker.CheckRequest, ret *Sc return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) } ret.RawResults.SASTResults = rawData + case checks.CheckSBOM: + rawData, err := raw.SBOM(request) + if err != nil { + return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + } + ret.RawResults.SBOMResults = rawData case checks.CheckSecurityPolicy: rawData, err := raw.SecurityPolicy(request) if err != nil { diff --git a/probes/entries.go b/probes/entries.go index ea6bdf43fd4f..527bb4858982 100644 --- a/probes/entries.go +++ b/probes/entries.go @@ -39,6 +39,8 @@ import ( "github.com/ossf/scorecard/v5/probes/hasOpenSSFBadge" "github.com/ossf/scorecard/v5/probes/hasPermissiveLicense" "github.com/ossf/scorecard/v5/probes/hasRecentCommits" + "github.com/ossf/scorecard/v5/probes/hasReleaseSBOM" + "github.com/ossf/scorecard/v5/probes/hasSBOM" "github.com/ossf/scorecard/v5/probes/hasUnverifiedBinaryArtifacts" "github.com/ossf/scorecard/v5/probes/issueActivityByProjectMember" "github.com/ossf/scorecard/v5/probes/jobLevelPermissions" @@ -130,6 +132,10 @@ var ( CITests = []ProbeImpl{ testsRunInCI.Run, } + SBOM = []ProbeImpl{ + hasSBOM.Run, + hasReleaseSBOM.Run, + } SignedReleases = []ProbeImpl{ releasesAreSigned.Run, releasesHaveProvenance.Run, diff --git a/probes/hasReleaseSBOM/def.yml b/probes/hasReleaseSBOM/def.yml new file mode 100644 index 000000000000..e8d911252b97 --- /dev/null +++ b/probes/hasReleaseSBOM/def.yml @@ -0,0 +1,36 @@ +# Copyright 2024 OpenSSF Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +id: hasReleaseSBOM +short: Check that the project publishes an SBOM as part of its release artifacts. +motivation: > + An SBOM can give users information about how the source code components and dependencies. They help facilitate sotware supplychain security and aid in identifying upstream vulnerabilities in a codebase. +implementation: > + The implementation checks whether a SBOM artifact is included in release artifacts. +outcome: + - If SBOM artifacts are found, the probe returns OutcomeTrue for each SBOM artifact up to 5. + - If an SBOM artifact is not found, the probe returns a single OutcomeFalse. +remediation: + onOutcome: False + effort: Low + text: + - For Github projects, start with [this guide](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security) to determine which steps are needed to generate an adequate SBOM. + - For Gitlab projects, see existing [Dependency Scanning](https://docs.gitlab.com/ee/user/application_security/dependency_scanning/index.html#cyclonedx-software-bill-of-materials) and [Container Scanning](https://docs.gitlab.com/ee/user/application_security/container_scanning/index.html#cyclonedx-software-bill-of-materials) tools. + - Alternatively, there are other tools available to generate [CycloneDX](https://cyclonedx.org/tool-center/) and [SPDX](https://spdx.dev/use/tools/) SBOMs. +ecosystem: + languages: + - all + clients: + - github + - gitlab \ No newline at end of file diff --git a/probes/hasReleaseSBOM/impl.go b/probes/hasReleaseSBOM/impl.go new file mode 100644 index 000000000000..ecf3d49ce1f2 --- /dev/null +++ b/probes/hasReleaseSBOM/impl.go @@ -0,0 +1,82 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//nolint:stylecheck +package hasReleaseSBOM + +import ( + "embed" + "fmt" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func init() { + probes.MustRegister(Probe, Run, []probes.CheckName{probes.SBOM}) +} + +//go:embed *.yml +var fs embed.FS + +const ( + Probe = "hasReleaseSBOM" + AssetNameKey = "assetName" + AssetURLKey = "assetURL" + missingSbom = "Project is not publishing an SBOM file as part of a release or CICD" +) + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + var findings []finding.Finding + var msg string + + SBOMFiles := raw.SBOMResults.SBOMFiles + + for i := range SBOMFiles { + SBOMFile := SBOMFiles[i] + + if SBOMFile.File.Type != finding.FileTypeURL { + continue + } + + loc := SBOMFile.File.Location() + msg = "Project publishes an SBOM file as part of a release or CICD" + f, err := finding.NewTrue(fs, Probe, msg, loc) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + f.Values = map[string]string{ + AssetNameKey: SBOMFile.Name, + AssetURLKey: SBOMFile.File.Path, + } + findings = append(findings, *f) + } + + if len(findings) == 0 { + msg = missingSbom + f, err := finding.NewFalse(fs, Probe, msg, nil) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + + return findings, Probe, nil +} diff --git a/probes/hasReleaseSBOM/impl_test.go b/probes/hasReleaseSBOM/impl_test.go new file mode 100644 index 000000000000..a11c8c85b812 --- /dev/null +++ b/probes/hasReleaseSBOM/impl_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//nolint:stylecheck +package hasReleaseSBOM + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + //nolint:govet + tests := []struct { + name string + raw *checker.RawResults + outcomes []finding.Outcome + err error + }{ + { + name: "Release SBOM file found and outcome should be positive", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: []checker.SBOM{ + { + File: checker.File{ + Path: "SBOM.cdx.json", + Type: finding.FileTypeURL, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "Release SBOM file not found and outcome should be negative", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: []checker.SBOM{ + { + File: checker.File{ + Path: "SBOM.cdx.json", + Type: finding.FileTypeSource, + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "SBOM file not found and outcome should be negative", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: []checker.SBOM{}, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "nil license files and outcome should be negative", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: nil, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "no raw data", + raw: nil, + err: uerror.ErrNil, + outcomes: nil, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + findings, s, err := Run(tt.raw) + if !cmp.Equal(tt.err, err, cmpopts.EquateErrors()) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(tt.err, err, cmpopts.EquateErrors())) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +} diff --git a/probes/hasSBOM/def.yml b/probes/hasSBOM/def.yml new file mode 100644 index 000000000000..d303b1ddcaa7 --- /dev/null +++ b/probes/hasSBOM/def.yml @@ -0,0 +1,36 @@ +# Copyright 2024 OpenSSF Scorecard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +id: hasSBOM +short: Check that the project has an SBOM file +motivation: > + An SBOM can give users information about how the source code components and dependencies. They help facilitate sotware supplychain security and aid in identifying upstream vulnerabilities in a codebase. +implementation: > + The implementation checks whether an SBOM file is present in the source code. +outcome: + - If an SBOM file(s) is found, the probe returns OutcomeTrue for each SBOM artifact up to 5. + - If an SBOM file is not found, the probe returns a single OutcomeFalse. +remediation: + onOutcome: False + effort: Low + text: + - For Github projects, start with [this guide](https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security) to determine which steps are needed to generate an adequate SBOM. + - For Gitlab projects, see existing [Dependency Scanning](https://docs.gitlab.com/ee/user/application_security/dependency_scanning/index.html#cyclonedx-software-bill-of-materials) and [Container Scanning](https://docs.gitlab.com/ee/user/application_security/container_scanning/index.html#cyclonedx-software-bill-of-materials) tools. + - Alternatively, there are other tools available to generate [CycloneDX](https://cyclonedx.org/tool-center/) and [SPDX](https://spdx.dev/use/tools/) SBOMs. +ecosystem: + languages: + - all + clients: + - github + - gitlab \ No newline at end of file diff --git a/probes/hasSBOM/impl.go b/probes/hasSBOM/impl.go new file mode 100644 index 000000000000..5ce606ff96b9 --- /dev/null +++ b/probes/hasSBOM/impl.go @@ -0,0 +1,69 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//nolint:stylecheck +package hasSBOM + +import ( + "embed" + "fmt" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/probes" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func init() { + probes.MustRegister(Probe, Run, []probes.CheckName{probes.SBOM}) +} + +//go:embed *.yml +var fs embed.FS + +const Probe = "hasSBOM" + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + var findings []finding.Finding + var msg string + + SBOMFiles := raw.SBOMResults.SBOMFiles + + if len(SBOMFiles) == 0 { + msg = "Project does not have a SBOM file" + f, err := finding.NewFalse(fs, Probe, msg, nil) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + return findings, Probe, nil + } + + for i := range SBOMFiles { + SBOMFile := SBOMFiles[i] + loc := SBOMFile.File.Location() + msg = "Project has a SBOM file" + f, err := finding.NewTrue(fs, Probe, msg, loc) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + + return findings, Probe, nil +} diff --git a/probes/hasSBOM/impl_test.go b/probes/hasSBOM/impl_test.go new file mode 100644 index 000000000000..47888ff89a69 --- /dev/null +++ b/probes/hasSBOM/impl_test.go @@ -0,0 +1,103 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//nolint:stylecheck +package hasSBOM + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v5/checker" + "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/probes/internal/utils/test" + "github.com/ossf/scorecard/v5/probes/internal/utils/uerror" +) + +func Test_Run(t *testing.T) { + t.Parallel() + //nolint:govet + tests := []struct { + name string + raw *checker.RawResults + outcomes []finding.Outcome + err error + }{ + { + name: "SBOM file found and outcome should be positive", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: []checker.SBOM{ + { + File: checker.File{ + Path: "SBOM.cdx.json", + }, + }, + }, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeTrue, + }, + }, + { + name: "nil SBOM files and outcome should be negative", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: nil, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "0 SBOM files and outcome should be negative", + raw: &checker.RawResults{ + SBOMResults: checker.SBOMData{ + SBOMFiles: []checker.SBOM{}, + }, + }, + outcomes: []finding.Outcome{ + finding.OutcomeFalse, + }, + }, + { + name: "no raw data", + raw: nil, + err: uerror.ErrNil, + outcomes: nil, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + findings, s, err := Run(tt.raw) + if !cmp.Equal(tt.err, err, cmpopts.EquateErrors()) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(tt.err, err, cmpopts.EquateErrors())) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.outcomes) + }) + } +}