🌱 Add license probe (#3465)

* 🌱 Add license probe

Signed-off-by: AdamKorcz <adam@adalogics.com>

* [WIP] add two remaining license checks as probes

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix nits

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Use Errorf in test

Signed-off-by: AdamKorcz <adam@adalogics.com>

* use zrunner

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix wrong return value

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix linting issues and remove empty default

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix double if statement

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Remove struct field from test

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Add test for nil-case of license files slice

Signed-off-by: AdamKorcz <adam@adalogics.com>

* rewrite multiple def.ymls

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix nits

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Add unit test with multiple unapproved license files

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Add link to approved license formats

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix linting

Signed-off-by: AdamKorcz <adam@adalogics.com>

* remove comment

Signed-off-by: AdamKorcz <adam@adalogics.com>

* preserve logging from original check

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix typo

Signed-off-by: AdamKorcz <adam@adalogics.com>

* remove redundant map manipulation

Signed-off-by: AdamKorcz <adam@adalogics.com>

* rename hasApproveLicense probe

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Return OutcomeNotApplicable if hasFSFOrOSIApprovedLicense probe does not find a license

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Include license file locations in log

Signed-off-by: AdamKorcz <adam@adalogics.com>

* fix linting issues

Signed-off-by: AdamKorcz <adam@adalogics.com>

* replace strings filtering with OutcomeNotApplicable in hasLicenseFileAtTopDir probe

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Fix linter issue

Signed-off-by: AdamKorcz <adam@adalogics.com>

* Include location of found license files

Signed-off-by: AdamKorcz <adam@adalogics.com>

---------

Signed-off-by: AdamKorcz <adam@adalogics.com>
This commit is contained in:
AdamKorcz 2023-10-24 19:48:41 +01:00 committed by GitHub
parent 622f10442c
commit 0e3a5233ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 985 additions and 199 deletions

View File

@ -18,78 +18,92 @@ import (
"github.com/ossf/scorecard/v4/checker"
sce "github.com/ossf/scorecard/v4/errors"
"github.com/ossf/scorecard/v4/finding"
"github.com/ossf/scorecard/v4/probes/hasFSFOrOSIApprovedLicense"
"github.com/ossf/scorecard/v4/probes/hasLicenseFile"
"github.com/ossf/scorecard/v4/probes/hasLicenseFileAtTopDir"
)
func scoreLicenseCriteria(f *checker.LicenseFile,
dl checker.DetailLogger,
) int {
var score int
msg := checker.LogMessage{
Path: "",
Type: finding.FileTypeNone,
Text: "",
Offset: 1,
}
msg.Path = f.File.Path
msg.Type = finding.FileTypeSource
// #1 a license file was found.
score += 6
// #2 the licence was found at the top-level or LICENSE/ folder.
switch f.LicenseInformation.Attribution {
case checker.LicenseAttributionTypeAPI, checker.LicenseAttributionTypeHeuristics:
// both repoAPI and scorecard (not using the API) follow checks.md
// for a file to be found it must have been in the correct location
// award location points.
score += 3
msg.Text = "License file found in expected location"
dl.Info(&msg)
// for repo attribution prepare warning if not an recognized license"
msg.Text = "Any licence detected not an FSF or OSI recognized license"
case checker.LicenseAttributionTypeOther:
// TODO ascertain location found
score += 0
msg.Text = "License file found in unexpected location"
dl.Warn(&msg)
// for non repo attribution not the license detection is not supported
msg.Text = "Detecting license content not supported"
default:
}
// #3 is the license either an FSF or OSI recognized/approved license
if f.LicenseInformation.Approved {
score += 1
msg.Text = "FSF or OSI recognized license"
dl.Info(&msg)
} else {
// message text for this condition set above
dl.Warn(&msg)
}
return score
}
// License applies the score policy for the License check.
func License(name string, dl checker.DetailLogger,
r *checker.LicenseData,
func License(name string,
findings []finding.Finding,
dl checker.DetailLogger,
) checker.CheckResult {
var score int
if r == nil {
e := sce.WithMessage(sce.ErrScorecardInternal, "empty raw data")
// We have 3 unique probes, each should have a finding.
expectedProbes := []string{
hasLicenseFile.Probe,
hasFSFOrOSIApprovedLicense.Probe,
hasLicenseFileAtTopDir.Probe,
}
if !finding.UniqueProbesEqual(findings, expectedProbes) {
e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
// Apply the policy evaluation.
if r.LicenseFiles == nil || len(r.LicenseFiles) == 0 {
// Compute the score.
score := 0
m := make(map[string]bool)
for i := range findings {
f := &findings[i]
switch f.Outcome {
case finding.OutcomeNotApplicable:
dl.Info(&checker.LogMessage{
Type: finding.FileTypeSource,
Offset: 1,
Text: f.Message,
})
case finding.OutcomePositive:
switch f.Probe {
case hasFSFOrOSIApprovedLicense.Probe:
dl.Info(&checker.LogMessage{
Type: finding.FileTypeSource,
Offset: 1,
Path: f.Message,
Text: "FSF or OSI recognized license",
})
score += scoreProbeOnce(f.Probe, m, 1)
case hasLicenseFileAtTopDir.Probe:
dl.Info(&checker.LogMessage{
Type: finding.FileTypeSource,
Offset: 1,
Path: f.Message,
Text: "License file found in expected location",
})
score += scoreProbeOnce(f.Probe, m, 3)
case hasLicenseFile.Probe:
score += scoreProbeOnce(f.Probe, m, 6)
default:
e := sce.WithMessage(sce.ErrScorecardInternal, "unknown probe results")
return checker.CreateRuntimeErrorResult(name, e)
}
case finding.OutcomeNegative:
switch f.Probe {
case hasLicenseFileAtTopDir.Probe:
dl.Warn(&checker.LogMessage{
Type: finding.FileTypeSource,
Offset: 1,
Path: f.Message,
Text: "License file found in unexpected location",
})
case hasFSFOrOSIApprovedLicense.Probe:
dl.Warn(&checker.LogMessage{
Type: finding.FileTypeSource,
Offset: 1,
Path: "",
Text: f.Message,
})
}
default:
continue // for linting
}
}
_, defined := m[hasLicenseFile.Probe]
if !defined {
if score > 0 {
e := sce.WithMessage(sce.ErrScorecardInternal, "score calculation problem")
return checker.CreateRuntimeErrorResult(name, e)
}
return checker.CreateMinScoreResult(name, "license file not detected")
}
// TODO: although this a loop, the raw checks will only return one licence file
// when more than one license file can be aggregated into a composite
// score, that logic can be comprehended here.
score = 0
for idx := range r.LicenseFiles {
score = scoreLicenseCriteria(&r.LicenseFiles[idx], dl)
}
return checker.CreateResultWithScore(name, "license file detected", score)
}

View File

@ -16,147 +16,136 @@ package evaluation
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ossf/scorecard/v4/checker"
sce "github.com/ossf/scorecard/v4/errors"
"github.com/ossf/scorecard/v4/finding"
scut "github.com/ossf/scorecard/v4/utests"
)
func Test_scoreLicenseCriteria(t *testing.T) {
t.Parallel()
type args struct {
f *checker.LicenseFile
dl checker.DetailLogger
}
tests := []struct { //nolint:govet
name string
args args
want int
}{
{
name: "License Attribution Type API",
args: args{
f: &checker.LicenseFile{
LicenseInformation: checker.License{
Attribution: checker.LicenseAttributionTypeAPI,
Approved: true,
},
},
dl: &scut.TestDetailLogger{},
},
want: 10,
},
{
name: "License Attribution Type Heuristics",
args: args{
f: &checker.LicenseFile{
LicenseInformation: checker.License{
Attribution: checker.LicenseAttributionTypeHeuristics,
},
},
dl: &scut.TestDetailLogger{},
},
want: 9,
},
{
name: "License Attribution Type Other",
args: args{
f: &checker.LicenseFile{
LicenseInformation: checker.License{
Attribution: checker.LicenseAttributionTypeOther,
},
},
dl: &scut.TestDetailLogger{},
},
want: 6,
},
{
name: "License Attribution Type Unknown",
args: args{
f: &checker.LicenseFile{
LicenseInformation: checker.License{
Attribution: "Unknown",
},
},
dl: &scut.TestDetailLogger{},
},
want: 6,
},
}
for _, tt := range tests {
tt := tt // Parallel testing scoping hack.
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := scoreLicenseCriteria(tt.args.f, tt.args.dl); got != tt.want {
t.Errorf("scoreLicenseCriteria() = %v, want %v", got, tt.want)
}
})
}
}
func TestLicense(t *testing.T) {
t.Parallel()
type args struct { //nolint:govet
name string
dl checker.DetailLogger
r *checker.LicenseData
}
tests := []struct {
name string
args args
want checker.CheckResult
name string
findings []finding.Finding
result scut.TestReturn
}{
{
name: "No License",
args: args{
name: "No License",
dl: &scut.TestDetailLogger{},
},
want: checker.CheckResult{
Score: -1,
Version: 2,
Reason: "internal error: empty raw data",
Name: "No License",
},
},
{
name: "No License Files",
args: args{
name: "No License Files",
dl: &scut.TestDetailLogger{},
r: &checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
name: "Positive outcome = Max Score",
findings: []finding.Finding{
{
Probe: "hasLicenseFile",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasFSFOrOSIApprovedLicense",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasLicenseFileAtTopDir",
Outcome: finding.OutcomePositive,
},
},
want: checker.CheckResult{
Score: 0,
Version: 2,
Reason: "license file not detected",
Name: "No License Files",
result: scut.TestReturn{
Score: checker.MaxResultScore,
NumberOfInfo: 2,
},
},
{
name: "License Files Detected",
args: args{
name: "License Files Detected",
dl: &scut.TestDetailLogger{},
r: &checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
LicenseInformation: checker.License{
Attribution: checker.LicenseAttributionTypeAPI,
Approved: true,
},
},
},
}, {
name: "Negative outcomes from all probes = Min score",
findings: []finding.Finding{
{
Probe: "hasLicenseFile",
Outcome: finding.OutcomeNegative,
},
{
Probe: "hasFSFOrOSIApprovedLicense",
Outcome: finding.OutcomeNegative,
},
{
Probe: "hasLicenseFileAtTopDir",
Outcome: finding.OutcomeNegative,
},
},
want: checker.CheckResult{
Score: 10,
Version: 2,
Reason: "license file detected",
Name: "License Files Detected",
result: scut.TestReturn{
Score: checker.MinResultScore,
NumberOfWarn: 2,
},
}, {
name: "Has license file but not a top level or in OSI/FSF format",
findings: []finding.Finding{
{
Probe: "hasLicenseFile",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasFSFOrOSIApprovedLicense",
Outcome: finding.OutcomeNegative,
},
{
Probe: "hasLicenseFileAtTopDir",
Outcome: finding.OutcomeNegative,
},
},
result: scut.TestReturn{
Score: 6,
NumberOfWarn: 2,
},
}, {
name: "Findings missing a probe = Error",
findings: []finding.Finding{
{
Probe: "hasLicenseFile",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasFSFOrOSIApprovedLicense",
Outcome: finding.OutcomeNegative,
},
},
result: scut.TestReturn{
Score: -1,
Error: sce.ErrScorecardInternal,
},
}, {
name: "Has a license at top dir but it is not OSI/FSF approved",
findings: []finding.Finding{
{
Probe: "hasLicenseFile",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasFSFOrOSIApprovedLicense",
Outcome: finding.OutcomeNegative,
},
{
Probe: "hasLicenseFileAtTopDir",
Outcome: finding.OutcomePositive,
},
},
result: scut.TestReturn{
Score: 9,
NumberOfInfo: 1,
NumberOfWarn: 1,
},
}, {
name: "Has an OSI/FSF approved license but not at top level dir",
findings: []finding.Finding{
{
Probe: "hasLicenseFile",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasFSFOrOSIApprovedLicense",
Outcome: finding.OutcomePositive,
},
{
Probe: "hasLicenseFileAtTopDir",
Outcome: finding.OutcomeNegative,
},
},
result: scut.TestReturn{
Score: 7,
NumberOfInfo: 1,
NumberOfWarn: 1,
},
},
}
@ -164,8 +153,10 @@ func TestLicense(t *testing.T) {
tt := tt // Parallel testing scoping hack.
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := License(tt.args.name, tt.args.dl, tt.args.r); !cmp.Equal(got, tt.want, cmpopts.IgnoreFields(checker.CheckResult{}, "Error")) { //nolint:lll
t.Errorf("License() = %v, want %v", got, cmp.Diff(got, tt.want, cmpopts.IgnoreFields(checker.CheckResult{}, "Error"))) //nolint:lll
dl := scut.TestDetailLogger{}
got := License(tt.name, tt.findings, &dl)
if !scut.ValidateTestReturn(t, tt.name, &tt.result, &got, &dl) {
t.Errorf("got %v, expected %v", got, tt.result)
}
})
}

View File

@ -19,6 +19,8 @@ import (
"github.com/ossf/scorecard/v4/checks/evaluation"
"github.com/ossf/scorecard/v4/checks/raw"
sce "github.com/ossf/scorecard/v4/errors"
"github.com/ossf/scorecard/v4/probes"
"github.com/ossf/scorecard/v4/probes/zrunner"
)
// CheckLicense is the registered name for License.
@ -44,9 +46,15 @@ func License(c *checker.CheckRequest) checker.CheckResult {
}
// Set the raw results.
if c.RawResults != nil {
c.RawResults.LicenseResults = rawData
pRawResults := getRawResults(c)
pRawResults.LicenseResults = rawData
// Evaluate the probes.
findings, err := zrunner.Run(pRawResults, probes.License)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckLicense, e)
}
return evaluation.License(CheckLicense, c.Dlogger, &rawData)
return evaluation.License(CheckLicense, findings, c.Dlogger)
}

View File

@ -42,7 +42,7 @@ func TestLicenseFileSubdirectory(t *testing.T) {
inputFolder: "testdata/licensedir/withlicense",
expected: scut.TestReturn{
Error: nil,
Score: checker.MaxResultScore - 1,
Score: 9, // Does not have approved format
NumberOfInfo: 1,
NumberOfWarn: 1,
},
@ -52,8 +52,10 @@ func TestLicenseFileSubdirectory(t *testing.T) {
name: "Without LICENSE",
inputFolder: "testdata/licensedir/withoutlicense",
expected: scut.TestReturn{
Error: nil,
Score: checker.MinResultScore,
Error: nil,
Score: checker.MinResultScore,
NumberOfWarn: 0,
NumberOfInfo: 2,
},
err: nil,
},

View File

@ -30,6 +30,9 @@ import (
"github.com/ossf/scorecard/v4/probes/fuzzedWithPythonAtheris"
"github.com/ossf/scorecard/v4/probes/fuzzedWithRustCargofuzz"
"github.com/ossf/scorecard/v4/probes/fuzzedWithSwiftLibFuzzer"
"github.com/ossf/scorecard/v4/probes/hasFSFOrOSIApprovedLicense"
"github.com/ossf/scorecard/v4/probes/hasLicenseFile"
"github.com/ossf/scorecard/v4/probes/hasLicenseFileAtTopDir"
"github.com/ossf/scorecard/v4/probes/securityPolicyContainsLinks"
"github.com/ossf/scorecard/v4/probes/securityPolicyContainsText"
"github.com/ossf/scorecard/v4/probes/securityPolicyContainsVulnerabilityDisclosure"
@ -77,6 +80,11 @@ var (
fuzzedWithPropertyBasedTypescript.Run,
fuzzedWithPropertyBasedJavascript.Run,
}
License = []ProbeImpl{
hasLicenseFile.Run,
hasFSFOrOSIApprovedLicense.Run,
hasLicenseFileAtTopDir.Run,
}
)
//nolint:gochecknoinits
@ -85,6 +93,7 @@ func init() {
DependencyToolUpdates,
SecurityPolicy,
Fuzzing,
License,
})
}

View File

@ -0,0 +1,28 @@
# Copyright 2023 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: hasFSFOrOSIApprovedLicense
short: Check that the project has an FSF or OSI approved license. For more, see [all FSF or OSI approved license formats](https://spdx.org/licenses/).
motivation: >
A license can give users information about how the source code may or may not be used. The lack of a license will impede any kind of security review or audit and creates a legal risk for potential users.
implementation: >
The implementation checks whether a license file is present and is of an approved format
outcome:
- If a license file is found and is of an approved format, the probe returns a single OutcomePositive.
- If a license file is missing the probe returns a single OutcomeNotApplicable.
- If the license is not of an approved format, the probe returns a single OutcomeNegative.
remediation:
effort: Low
text:
- Update the license file format in the Github repository to be of an approved format.

View File

@ -0,0 +1,69 @@
// Copyright 2023 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 hasFSFOrOSIApprovedLicense
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/finding"
"github.com/ossf/scorecard/v4/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
const Probe = "hasFSFOrOSIApprovedLicense"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
if raw.LicenseResults.LicenseFiles == nil || len(raw.LicenseResults.LicenseFiles) == 0 {
f, err := finding.NewWith(fs, Probe,
"project does not have a license file", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
for _, licenseFile := range raw.LicenseResults.LicenseFiles {
if licenseFile.LicenseInformation.Approved {
// Store the file path in the msg
msg := licenseFile.File.Path
f, err := finding.NewWith(fs, Probe,
msg, nil,
finding.OutcomePositive)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
}
f, err := finding.NewWith(fs, Probe,
"project license file does not contain an FSF or OSI license.", nil,
finding.OutcomeNegative)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}

View File

@ -0,0 +1,166 @@
// Copyright 2023 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 hasFSFOrOSIApprovedLicense
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/finding"
)
func Test_Run(t *testing.T) {
t.Parallel()
// nolint:govet
tests := []struct {
name string
raw *checker.RawResults
outcomes []finding.Outcome
err error
}{
{
name: "License file found and is approved: outcome should be positive",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "LICENSE.md",
},
LicenseInformation: checker.License{
Approved: true,
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomePositive,
},
},
{
name: "License file found and is not approved: outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "LICENSE.md",
},
LicenseInformation: checker.License{
Approved: false,
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
{
name: "License file not found and outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNotApplicable,
},
},
{
name: "License file found but is not approved. Outcome should be Negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "LICENSE.md",
},
LicenseInformation: checker.License{
Attribution: "wrong attribution",
},
},
{
File: checker.File{
Path: "COPYING.md",
},
LicenseInformation: checker.License{
Attribution: "wrong attribution2",
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
{
name: "nil license files and outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: nil,
},
},
outcomes: []finding.Outcome{
finding.OutcomeNotApplicable,
},
},
{
name: "0 license files and outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNotApplicable,
},
},
}
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)
}
if diff := cmp.Diff(len(tt.outcomes), len(findings)); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
for i := range tt.outcomes {
outcome := &tt.outcomes[i]
f := &findings[i]
if diff := cmp.Diff(*outcome, f.Outcome); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
}

View File

@ -0,0 +1,29 @@
# Copyright 2023 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: hasLicenseFile
short: Check that the project has a license file
motivation: >
A license can give users information about how the source code may or may not be used. The lack of a license will impede any kind of security review or audit and creates a legal risk for potential users.
implementation: >
The implementation checks whether a license file is present.
outcome:
- If license files are found, the probe returns OutcomePositive for each license file.
- If a license file is not found, the probe returns a single OutcomeNegative.
remediation:
effort: Low
text:
- For Github projects, follow [this guide](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/licensing-a-repository) to determine which license to apply to your project and establish a license file for your project.
- For Gitlab projects, create the license in a .adoc, .asc, .docx, .doc, .ext, .html, .markdown, .md, .rst, .txt, or .xml, named LICENSE, COPYRIGHT, or COPYING, and place it in the top-level directory. To identify a specific license, use an SPDX license identifier in the filename. Examples include LICENSE.md, Apache-2.0-LICENSE.md or LICENSE-Apache-2.0.
- Alternately, create a LICENSE directory and add a license file(s) with a name that matches your SPDX license identifier. such as LICENSES/Apache-2.0.txt.

View File

@ -0,0 +1,76 @@
// Copyright 2023 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 hasLicenseFile
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/finding"
"github.com/ossf/scorecard/v4/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
const Probe = "hasLicenseFile"
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 outcome finding.Outcome
var msg string
licenseFiles := raw.LicenseResults.LicenseFiles
if len(licenseFiles) == 0 {
outcome = finding.OutcomeNegative
msg = "project does not have a license file"
f, err := finding.NewWith(fs, Probe,
msg, nil,
outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
return findings, Probe, nil
} else {
for _, licenseFile := range licenseFiles {
licenseFile := licenseFile
loc := &finding.Location{
Type: licenseFile.File.Type,
Path: licenseFile.File.Path,
LineStart: &licenseFile.File.Offset,
LineEnd: &licenseFile.File.EndOffset,
Snippet: &licenseFile.File.Snippet,
}
msg = "project has a license file"
outcome = finding.OutcomePositive
f, err := finding.NewWith(fs, Probe,
msg, loc,
outcome)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
findings = append(findings, *f)
}
}
return findings, Probe, nil
}

View File

@ -0,0 +1,126 @@
// Copyright 2023 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 hasLicenseFile
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/finding"
)
func Test_Run(t *testing.T) {
t.Parallel()
// nolint:govet
tests := []struct {
name string
raw *checker.RawResults
outcomes []finding.Outcome
err error
}{
{
name: "License file found and outcome should be positive",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "SECURITY.md",
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomePositive,
},
},
{
name: "License file not found and outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
{
name: "nil license files and outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: nil,
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
{
name: "0 license files and outcome should be negative",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
{
name: "License file is nil",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: nil,
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
}
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)
}
if diff := cmp.Diff(len(tt.outcomes), len(findings)); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
for i := range tt.outcomes {
outcome := &tt.outcomes[i]
f := &findings[i]
if diff := cmp.Diff(*outcome, f.Outcome); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
}

View File

@ -0,0 +1,27 @@
# Copyright 2023 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: hasLicenseFileAtTopDir
short: Check that the project has a license file
motivation: >
A license can give users information about how the source code may or may not be used. The lack of a license will impede any kind of security review or audit and creates a legal risk for potential users.
implementation: >
This check will detect files in the top-level directory with any combination of the following names and extensions: LICENSE, LICENCE, COPYING, COPYRIGHT and having common extensions such as .html, .txt, or .md. It will also detect these files in a directory named LICENSES in the top directory. (Files in a LICENSES directory are typically named as their SPDX license identifier followed by an appropriate file extension, as described in the [REUSE Specification](https://reuse.software/spec/).)
outcome:
- If the projects license file is found at the top level, the probe returns a single OutcomePositive (1).
- If the projects license file is not found at the top level, the probe returns a single OutcomeNegative (0).
remediation:
effort: Low
text:
- Place the license file at the top level of the project source tree.

View File

@ -0,0 +1,83 @@
// Copyright 2023 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 hasLicenseFileAtTopDir
import (
"embed"
"fmt"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/finding"
"github.com/ossf/scorecard/v4/probes/internal/utils/uerror"
)
//go:embed *.yml
var fs embed.FS
const Probe = "hasLicenseFileAtTopDir"
func Run(raw *checker.RawResults) ([]finding.Finding, string, error) {
if raw == nil {
return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil)
}
if raw.LicenseResults.LicenseFiles == nil || len(raw.LicenseResults.LicenseFiles) == 0 {
f, err := finding.NewWith(fs, Probe,
"project does not have a license file", nil,
finding.OutcomeNotApplicable)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
for _, licenseFile := range raw.LicenseResults.LicenseFiles {
switch licenseFile.LicenseInformation.Attribution {
case checker.LicenseAttributionTypeAPI, checker.LicenseAttributionTypeHeuristics:
// both repoAPI and scorecard (not using the API) follow checks.md
// for a file to be found it must have been in the correct location
// award location points.
// Store the file path in the msg
msg := licenseFile.File.Path
f, err := finding.NewWith(fs, Probe,
msg, nil,
finding.OutcomePositive)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
case checker.LicenseAttributionTypeOther:
msg := "License file found in unexpected location"
f, err := finding.NewWith(fs, Probe,
msg, nil,
finding.OutcomeNegative)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}
}
f, err := finding.NewWith(fs, Probe,
"Did not find the license file at the expected location.", nil,
finding.OutcomeNegative)
if err != nil {
return nil, Probe, fmt.Errorf("create finding: %w", err)
}
return []finding.Finding{*f}, Probe, nil
}

View File

@ -0,0 +1,158 @@
// Copyright 2023 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 hasLicenseFileAtTopDir
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ossf/scorecard/v4/checker"
"github.com/ossf/scorecard/v4/finding"
)
func Test_Run(t *testing.T) {
t.Parallel()
// nolint:govet
tests := []struct {
name string
raw *checker.RawResults
outcomes []finding.Outcome
err error
}{
{
name: "License file found and correct attribution 1",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "LICENSE.md",
},
LicenseInformation: checker.License{
Attribution: checker.LicenseAttributionTypeAPI,
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomePositive,
},
},
{
name: "License file not found and outcome should be OutcomeNotApplicable",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNotApplicable,
},
},
{
name: "License file found and correct attribution 2",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "LICENSE.md",
},
LicenseInformation: checker.License{
Attribution: checker.LicenseAttributionTypeHeuristics,
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomePositive,
},
},
{
name: "License file found and wrong attribution",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{
{
File: checker.File{
Path: "LICENSE.md",
},
LicenseInformation: checker.License{
Attribution: "wrong_attribution",
},
},
},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNegative,
},
},
{
name: "nil license files and outcome should be OutcomeNotApplicable",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: nil,
},
},
outcomes: []finding.Outcome{
finding.OutcomeNotApplicable,
},
},
{
name: "0 license files and outcome should be OutcomeNotApplicable",
raw: &checker.RawResults{
LicenseResults: checker.LicenseData{
LicenseFiles: []checker.LicenseFile{},
},
},
outcomes: []finding.Outcome{
finding.OutcomeNotApplicable,
},
},
}
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)
}
if diff := cmp.Diff(len(tt.outcomes), len(findings)); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
for i := range tt.outcomes {
outcome := &tt.outcomes[i]
f := &findings[i]
if diff := cmp.Diff(*outcome, f.Outcome); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
}
})
}
}