[experimental] Create probes within findings (#2919)

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

* update

Signed-off-by: laurentsimon <laurentsimon@google.com>

---------

Signed-off-by: laurentsimon <laurentsimon@google.com>
This commit is contained in:
laurentsimon 2023-05-02 17:42:32 -07:00 committed by GitHub
parent 700faf1d2d
commit a4da39a779
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 546 additions and 133 deletions

View File

@ -12,8 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
id: gitHubWorkflowPermissionsStepsNoWrite
short: Checks that GitHub workflows do not have steps with dangerous write permissions
desc: This rule checks that GitHub workflows do not have steps with dangerous write permissions
motivation: >
Even with permissions default set to read, some scopes having write permissions in their steps brings incurs a risk to the project.
By giving write permission to the Actions you call in jobs, an external Action you call could abuse them. Depending on the permissions,
@ -21,10 +21,9 @@ motivation: >
For more information about the scopes and the vulnerabilities involved, see https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions.
implementation: >
The rule is implemented by checking whether the `permissions` keyword is given non-write permissions for the following
The probe is implemented by checking whether the `permissions` keyword is given non-write permissions for the following
scopes: `statuses`, `checks`, `security-events`, `deployments`, `contents`, `packages`, `actions`.
Write permissions given to recognized packaging actions or commands are allowed and are considered an acceptable risk.
risk: Medium
remediation:
effort: High
text:

View File

@ -12,8 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
id: gitHubWorkflowPermissionsTopNoWrite
short: Checks that GitHub workflows do not have default write permissions
desc: This rule checks that GitHub workflows do not have default write permissions
motivation: >
If no permissions are declared, a workflow's GitHub token's permissions default to write for all scopes.
This include write permissions to push to the repository, to read encrypted secrets, etc.
@ -21,16 +21,15 @@ motivation: >
implementation: >
The rule is implemented by checking whether the `permissions` keyword is defined at the top of the workflow,
and that no write permissions are given.
risk: High
remediation:
effort: Low
text:
- Visit https://app.stepsecurity.io/secureworkflow/${{ repo }}/${{ workflow }}/${{ branch }}?enable=permissions
- Visit https://app.stepsecurity.io/secureworkflow/${{ metadata.repo }}/${{ metadata.workflow }}/${{ metadata.branch }}?enable=permissions
- Tick the 'Restrict permissions for GITHUB_TOKEN'
- Untick other options
- "NOTE: If you want to resolve multiple issues at once, you can visit https://app.stepsecurity.io/securerepo instead."
markdown:
- Visit [https://app.stepsecurity.io/secureworkflow](https://app.stepsecurity.io/secureworkflow/${{ repo }}/${{ workflow }}/${{ branch }}?enable=permissions).
- Visit [https://app.stepsecurity.io/secureworkflow](https://app.stepsecurity.io/secureworkflow/${{ metadata.repo }}/${{ metadata.workflow }}/${{ metadata.branch }}?enable=permissions).
- Tick the 'Restrict permissions for GITHUB_TOKEN'
- Untick other options
- "NOTE: If you want to resolve multiple issues at once, you can visit [https://app.stepsecurity.io/securerepo](https://app.stepsecurity.io/securerepo) instead."

View File

@ -26,13 +26,18 @@ import (
)
//go:embed *.yml
var rules embed.FS
var probes embed.FS
type permissions struct {
topLevelWritePermissions map[string]bool
jobLevelWritePermissions map[string]bool
}
var (
stepsNoWriteID = "gitHubWorkflowPermissionsStepsNoWrite"
topNoWriteID = "gitHubWorkflowPermissionsTopNoWrite"
)
// TokenPermissions applies the score policy for the Token-Permissions check.
func TokenPermissions(name string, c *checker.CheckRequest, r *checker.TokenPermissionsData) checker.CheckResult {
if r == nil {
@ -67,9 +72,9 @@ func applyScorePolicy(results *checker.TokenPermissionsData, c *checker.CheckReq
dl := c.Dlogger
//nolint:errcheck
remediationMetadata, _ := remediation.New(c)
negativeRuleResults := map[string]bool{
"GitHubWorkflowPermissionsStepsNoWrite": false,
"GitHubWorkflowPermissionsTopNoWrite": false,
negativeProbeResults := map[string]bool{
stepsNoWriteID: false,
topNoWriteID: false,
}
for _, r := range results.TokenPermissions {
@ -77,14 +82,12 @@ func applyScorePolicy(results *checker.TokenPermissionsData, c *checker.CheckReq
if r.File != nil {
loc = &finding.Location{
Type: r.File.Type,
Value: r.File.Path,
Path: r.File.Path,
LineStart: &r.File.Offset,
}
if r.File.Snippet != "" {
loc.Snippet = &r.File.Snippet
}
loc.Value = r.File.Path
}
text, err := createText(r)
@ -112,7 +115,7 @@ func applyScorePolicy(results *checker.TokenPermissionsData, c *checker.CheckReq
// We warn only for top-level.
if *r.LocationType == checker.PermissionLocationTop {
warnWithRemediation(dl, msg, remediationMetadata, loc, negativeRuleResults)
warnWithRemediation(dl, msg, remediationMetadata, loc, negativeProbeResults)
} else {
dl.Debug(msg)
}
@ -123,7 +126,7 @@ func applyScorePolicy(results *checker.TokenPermissionsData, c *checker.CheckReq
}
case checker.PermissionLevelWrite:
warnWithRemediation(dl, msg, remediationMetadata, loc, negativeRuleResults)
warnWithRemediation(dl, msg, remediationMetadata, loc, negativeProbeResults)
// Group results by workflow name for score computation.
if err := updateWorkflowHashMap(hm, r); err != nil {
@ -132,7 +135,7 @@ func applyScorePolicy(results *checker.TokenPermissionsData, c *checker.CheckReq
}
}
if err := reportDefaultFindings(results, c.Dlogger, negativeRuleResults); err != nil {
if err := reportDefaultFindings(results, c.Dlogger, negativeProbeResults); err != nil {
return checker.InconclusiveResultScore, err
}
@ -140,17 +143,18 @@ func applyScorePolicy(results *checker.TokenPermissionsData, c *checker.CheckReq
}
func reportDefaultFindings(results *checker.TokenPermissionsData,
dl checker.DetailLogger, negativeRuleResults map[string]bool,
dl checker.DetailLogger, negativeProbeResults map[string]bool,
) error {
// TODO(#2928): re-visit the need for NotApplicable outcome.
// No workflow files exist.
if len(results.TokenPermissions) == 0 {
text := "no workflows found in the repository"
if err := reportFinding("GitHubWorkflowPermissionsStepsNoWrite",
text, finding.OutcomeNotApplicable, dl); err != nil {
if err := reportFinding(stepsNoWriteID,
text, finding.OutcomeNotAvailable, dl); err != nil {
return err
}
if err := reportFinding("GitHubWorkflowPermissionsTopNoWrite",
text, finding.OutcomeNotApplicable, dl); err != nil {
if err := reportFinding(topNoWriteID,
text, finding.OutcomeNotAvailable, dl); err != nil {
return err
}
return nil
@ -158,12 +162,12 @@ func reportDefaultFindings(results *checker.TokenPermissionsData,
// Workflow files found, report positive findings if no
// negative findings were found.
// NOTE: we don't consider rule `GitHubWorkflowPermissionsTopNoWrite`
// NOTE: we don't consider probe `topNoWriteID`
// because positive results are already reported.
found := negativeRuleResults["GitHubWorkflowPermissionsStepsNoWrite"]
found := negativeProbeResults[stepsNoWriteID]
if !found {
text := fmt.Sprintf("no %s write permissions found", checker.PermissionLocationJob)
if err := reportFinding("GitHubWorkflowPermissionsStepsNoWrite",
if err := reportFinding(stepsNoWriteID,
text, finding.OutcomePositive, dl); err != nil {
return err
}
@ -172,8 +176,12 @@ func reportDefaultFindings(results *checker.TokenPermissionsData,
return nil
}
func reportFinding(rule, text string, o finding.Outcome, dl checker.DetailLogger) error {
f, err := finding.New(rules, rule)
func reportFinding(probe, text string, o finding.Outcome, dl checker.DetailLogger) error {
content, err := probes.ReadFile(probe + ".yml")
if err != nil {
return fmt.Errorf("%w", err)
}
f, err := finding.FromBytes(content, probe)
if err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
@ -185,11 +193,15 @@ func reportFinding(rule, text string, o finding.Outcome, dl checker.DetailLogger
}
func createLogMsg(loct *checker.PermissionLocation) (*checker.LogMessage, error) {
Rule := "GitHubWorkflowPermissionsStepsNoWrite"
probe := stepsNoWriteID
if loct == nil || *loct == checker.PermissionLocationTop {
Rule = "GitHubWorkflowPermissionsTopNoWrite"
probe = topNoWriteID
}
f, err := finding.New(rules, Rule)
content, err := probes.ReadFile(probe + ".yml")
if err != nil {
return nil, fmt.Errorf("%w", err)
}
f, err := finding.FromBytes(content, probe)
if err != nil {
return nil,
sce.WithMessage(sce.ErrScorecardInternal, err.Error())
@ -201,19 +213,19 @@ func createLogMsg(loct *checker.PermissionLocation) (*checker.LogMessage, error)
func warnWithRemediation(logger checker.DetailLogger, msg *checker.LogMessage,
rem *remediation.RemediationMetadata, loc *finding.Location,
negativeRuleResults map[string]bool,
negativeProbeResults map[string]bool,
) {
if loc != nil && loc.Value != "" {
if loc != nil && loc.Path != "" {
msg.Finding = msg.Finding.WithRemediationMetadata(map[string]string{
"repo": rem.Repo,
"branch": rem.Branch,
"workflow": strings.TrimPrefix(loc.Value, ".github/workflows/"),
"workflow": strings.TrimPrefix(loc.Path, ".github/workflows/"),
})
}
logger.Warn(msg)
// Record that we found a negative result.
negativeRuleResults[msg.Finding.Rule] = true
negativeProbeResults[msg.Finding.Probe] = true
}
func recordPermissionWrite(hm map[string]permissions, path string,

View File

@ -319,8 +319,8 @@ func TestGithubTokenPermissions(t *testing.T) {
Error: nil,
Score: checker.MaxResultScore,
NumberOfWarn: 0,
NumberOfInfo: 2, // This is constant.
NumberOfDebug: 8, // This is 4 + (number of actions)
NumberOfInfo: 2, // This is constant.
NumberOfDebug: 8, // This is 4 + (number of actions)
},
},
{
@ -488,7 +488,7 @@ func TestGithubTokenPermissionsLineNumber(t *testing.T) {
logMessage.Finding.Location != nil &&
logMessage.Finding.Location.LineStart != nil &&
*logMessage.Finding.Location.LineStart == expectedLog.lineNumber &&
logMessage.Finding.Location.Value == p &&
logMessage.Finding.Location.Path == p &&
logType == checker.DetailWarn
}
if !scut.ValidateLogMessage(isExpectedLog, &dl) {

View File

@ -16,10 +16,13 @@ package finding
import (
"embed"
"errors"
"fmt"
"strings"
"github.com/ossf/scorecard/v4/rule"
"gopkg.in/yaml.v3"
"github.com/ossf/scorecard/v4/finding/probe"
)
// FileType is the type of a file.
@ -42,7 +45,7 @@ const (
// nolint: govet
type Location struct {
Type FileType `json:"type"`
Value string `json:"value"`
Path string `json:"path"`
LineStart *uint `json:"lineStart,omitempty"`
LineEnd *uint `json:"lineEnd,omitempty"`
Snippet *string `json:"snippet,omitempty"`
@ -51,13 +54,33 @@ type Location struct {
// Outcome is the result of a finding.
type Outcome int
// TODO(#2928): re-visit the finding definitions.
const (
// NOTE: The additional '_' are intended for future use.
// This allows adding outcomes without breaking the values
// of existing outcomes.
// OutcomeNegative indicates a negative outcome.
OutcomeNegative Outcome = iota
_
_
_
// OutcomeNotAvailable indicates an unavailable outcome,
// typically because an API call did not return an answer.
OutcomeNotAvailable
_
_
_
// OutcomeError indicates an errors while running.
// The results could not be determined.
OutcomeError
_
_
_
// OutcomePositive indicates a positive outcome.
OutcomePositive
// OutcomeNotApplicable indicates a non-applicable outcome.
OutcomeNotApplicable
_
_
_
// OutcomeNotSupported indicates a non-supported outcome.
OutcomeNotSupported
)
@ -65,32 +88,92 @@ const (
// Finding represents a finding.
// nolint: govet
type Finding struct {
Rule string `json:"rule"`
Outcome Outcome `json:"outcome"`
Risk rule.Risk `json:"risk"`
Message string `json:"message"`
Location *Location `json:"location,omitempty"`
Remediation *rule.Remediation `json:"remediation,omitempty"`
Probe string `json:"probe"`
Outcome Outcome `json:"outcome"`
Message string `json:"message"`
Location *Location `json:"location,omitempty"`
Remediation *probe.Remediation `json:"remediation,omitempty"`
}
// New creates a new finding.
func New(loc embed.FS, ruleID string) (*Finding, error) {
r, err := rule.New(loc, ruleID)
// AnonymousFinding is a finding without a corerpsonding probe ID.
type AnonymousFinding struct {
Finding
// Remove the probe ID from
// the structure until the probes are GA.
Probe string `json:"probe,omitempty"`
}
var errInvalid = errors.New("invalid")
// FromBytes creates a finding for a probe given its config file's content.
func FromBytes(content []byte, probeID string) (*Finding, error) {
p, err := probe.FromBytes(content, probeID)
if err != nil {
// nolint
return nil, err
}
f := &Finding{
Rule: ruleID,
Probe: p.ID,
Outcome: OutcomeNegative,
Remediation: r.Remediation,
}
if r.Remediation != nil {
f.Risk = r.Risk
Remediation: p.Remediation,
}
return f, nil
}
// New creates a new finding.
func New(loc embed.FS, probeID string) (*Finding, error) {
p, err := probe.New(loc, probeID)
if err != nil {
return nil, fmt.Errorf("%w", err)
}
f := &Finding{
Probe: p.ID,
Outcome: OutcomeNegative,
Remediation: p.Remediation,
}
return f, nil
}
// NewWith create a finding with the desried location and outcome.
func NewWith(efs embed.FS, probeID, text string, loc *Location,
o Outcome,
) (*Finding, error) {
f, err := New(efs, probeID)
if err != nil {
return nil, fmt.Errorf("finding.New: %w", err)
}
f = f.WithMessage(text).WithOutcome(o).WithLocation(loc)
return f, nil
}
// NewWith create a negative finding with the desried location.
func NewNegative(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeNegative)
}
// NewNotAvailable create a finding with a NotAvailable outcome and the desried location.
func NewNotAvailable(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomeNotAvailable)
}
// NewPositive create a positive finding with the desried location.
func NewPositive(efs embed.FS, probeID, text string, loc *Location,
) (*Finding, error) {
return NewWith(efs, probeID, text, loc, OutcomePositive)
}
// Anonymize removes the probe ID and outcome
// from the finding. It is a temporary solution
// to integrate the code in the details without exposing
// too much information.
func (f *Finding) Anonymize() *AnonymousFinding {
return &AnonymousFinding{Finding: *f}
}
// WithMessage adds a message to an existing finding.
// No copy is made.
func (f *Finding) WithMessage(text string) *Finding {
@ -102,6 +185,13 @@ func (f *Finding) WithMessage(text string) *Finding {
// No copy is made.
func (f *Finding) WithLocation(loc *Location) *Finding {
f.Location = loc
if f.Remediation != nil && f.Location != nil {
// Replace location data.
f.Remediation.Text = strings.Replace(f.Remediation.Text,
"${{ finding.location.path }}", f.Location.Path, -1)
f.Remediation.Markdown = strings.Replace(f.Remediation.Markdown,
"${{ finding.location.path }}", f.Location.Path, -1)
}
return f
}
@ -109,6 +199,8 @@ func (f *Finding) WithLocation(loc *Location) *Finding {
// No copy is made.
func (f *Finding) WithPatch(patch *string) *Finding {
f.Remediation.Patch = patch
// NOTE: we will update the remediation section
// using patch information, e.g. ${{ patch.content }}.
return f
}
@ -131,16 +223,37 @@ func (f *Finding) WithRemediationMetadata(values map[string]string) *Finding {
if f.Remediation != nil {
// Replace all dynamic values.
for k, v := range values {
// Replace metadata.
f.Remediation.Text = strings.Replace(f.Remediation.Text,
fmt.Sprintf("${{ %s }}", k), v, -1)
fmt.Sprintf("${{ metadata.%s }}", k), v, -1)
f.Remediation.Markdown = strings.Replace(f.Remediation.Markdown,
fmt.Sprintf("${{ %s }}", k), v, -1)
fmt.Sprintf("${{ metadata.%s }}", k), v, -1)
}
}
return f
}
// WorseThan compares outcomes.
func (o *Outcome) WorseThan(oo Outcome) bool {
return *o < oo
// UnmarshalYAML is a custom unmarshalling function
// to transform the string into an enum.
func (o *Outcome) UnmarshalYAML(n *yaml.Node) error {
var str string
if err := n.Decode(&str); err != nil {
return fmt.Errorf("decode: %w", err)
}
switch n.Value {
case "Negative":
*o = OutcomeNegative
case "Positive":
*o = OutcomePositive
case "NotAvailable":
*o = OutcomeNotAvailable
case "NotSupported":
*o = OutcomeNotSupported
case "Error":
*o = OutcomeError
default:
return fmt.Errorf("%w: %q", errInvalid, str)
}
return nil
}

View File

@ -15,24 +15,21 @@
package finding
import (
"embed"
"errors"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/ossf/scorecard/v4/rule"
"github.com/ossf/scorecard/v4/finding/probe"
)
func errCmp(e1, e2 error) bool {
return errors.Is(e1, e2) || errors.Is(e2, e1)
}
//go:embed testdata/*
var testfs embed.FS
func Test_New(t *testing.T) {
func Test_FromBytes(t *testing.T) {
snippet := "some code snippet"
patch := "some patch values"
sline := uint(10)
@ -44,106 +41,92 @@ func Test_New(t *testing.T) {
tests := []struct {
name string
id string
path string
outcome *Outcome
err error
metadata map[string]string
finding *Finding
}{
{
name: "risk high",
id: "testdata/risk-high",
outcome: &negativeOutcome,
finding: &Finding{
Rule: "testdata/risk-high",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Text: "step1\nstep2 https://www.google.com/something",
Markdown: "step1\nstep2 [google.com](https://www.google.com/something)",
Effort: rule.RemediationEffortLow,
},
},
},
{
name: "effort low",
id: "testdata/effort-low",
id: "effort-low",
path: "testdata/effort-low.yml",
outcome: &negativeOutcome,
finding: &Finding{
Rule: "testdata/effort-low",
Probe: "effort-low",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Remediation: &probe.Remediation{
Text: "step1\nstep2 https://www.google.com/something",
Markdown: "step1\nstep2 [google.com](https://www.google.com/something)",
Effort: rule.RemediationEffortLow,
Effort: probe.RemediationEffortLow,
},
},
},
{
name: "effort high",
id: "testdata/effort-high",
id: "effort-high",
path: "testdata/effort-high.yml",
outcome: &negativeOutcome,
finding: &Finding{
Rule: "testdata/effort-high",
Probe: "effort-high",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Remediation: &probe.Remediation{
Text: "step1\nstep2 https://www.google.com/something",
Markdown: "step1\nstep2 [google.com](https://www.google.com/something)",
Effort: rule.RemediationEffortHigh,
Effort: probe.RemediationEffortHigh,
},
},
},
{
name: "env variables",
id: "testdata/metadata-variables",
id: "metadata-variables",
path: "testdata/metadata-variables.yml",
outcome: &negativeOutcome,
metadata: map[string]string{"branch": "master", "repo": "ossf/scorecard"},
finding: &Finding{
Rule: "testdata/metadata-variables",
Probe: "metadata-variables",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Remediation: &probe.Remediation{
Text: "step1\nstep2 google.com/ossf/scorecard@master",
Markdown: "step1\nstep2 [google.com/ossf/scorecard@master](google.com/ossf/scorecard@master)",
Effort: rule.RemediationEffortLow,
Effort: probe.RemediationEffortLow,
},
},
},
{
name: "patch",
id: "testdata/metadata-variables",
id: "metadata-variables",
path: "testdata/metadata-variables.yml",
outcome: &negativeOutcome,
metadata: map[string]string{"branch": "master", "repo": "ossf/scorecard"},
finding: &Finding{
Rule: "testdata/metadata-variables",
Probe: "metadata-variables",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Remediation: &probe.Remediation{
Text: "step1\nstep2 google.com/ossf/scorecard@master",
Markdown: "step1\nstep2 [google.com/ossf/scorecard@master](google.com/ossf/scorecard@master)",
Effort: rule.RemediationEffortLow,
Effort: probe.RemediationEffortLow,
Patch: &patch,
},
},
},
{
name: "location",
id: "testdata/metadata-variables",
id: "metadata-variables",
path: "testdata/metadata-variables.yml",
outcome: &negativeOutcome,
metadata: map[string]string{"branch": "master", "repo": "ossf/scorecard"},
finding: &Finding{
Rule: "testdata/metadata-variables",
Probe: "metadata-variables",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Remediation: &probe.Remediation{
Text: "step1\nstep2 google.com/ossf/scorecard@master",
Markdown: "step1\nstep2 [google.com/ossf/scorecard@master](google.com/ossf/scorecard@master)",
Effort: rule.RemediationEffortLow,
Effort: probe.RemediationEffortLow,
},
Location: &Location{
Type: FileTypeSource,
Value: "path/to/file.txt",
Path: "path/to/file.txt",
LineStart: &sline,
LineEnd: &eline,
Snippet: &snippet,
@ -152,29 +135,29 @@ func Test_New(t *testing.T) {
},
{
name: "text",
id: "testdata/metadata-variables",
id: "metadata-variables",
path: "testdata/metadata-variables.yml",
outcome: &negativeOutcome,
metadata: map[string]string{"branch": "master", "repo": "ossf/scorecard"},
finding: &Finding{
Rule: "testdata/metadata-variables",
Probe: "metadata-variables",
Outcome: OutcomeNegative,
Risk: rule.RiskHigh,
Remediation: &rule.Remediation{
Remediation: &probe.Remediation{
Text: "step1\nstep2 google.com/ossf/scorecard@master",
Markdown: "step1\nstep2 [google.com/ossf/scorecard@master](google.com/ossf/scorecard@master)",
Effort: rule.RemediationEffortLow,
Effort: probe.RemediationEffortLow,
},
Message: "some text",
},
},
{
name: "outcome",
id: "testdata/metadata-variables",
name: "positive outcome",
id: "metadata-variables",
path: "testdata/metadata-variables.yml",
outcome: &positiveOutcome,
finding: &Finding{
Rule: "testdata/metadata-variables",
Probe: "metadata-variables",
Outcome: OutcomePositive,
Risk: rule.RiskHigh,
Message: "some text",
},
},
@ -184,7 +167,12 @@ func Test_New(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r, err := New(testfs, tt.id)
content, err := os.ReadFile(tt.path)
if err != nil {
t.Fatalf(err.Error())
}
r, err := FromBytes(content, tt.id)
if err != nil || tt.err != nil {
if !errCmp(err, tt.err) {
t.Fatalf("unexpected error: %v", cmp.Diff(err, tt.err, cmpopts.EquateErrors()))

183
finding/probe/probe.go Normal file
View File

@ -0,0 +1,183 @@
// 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.
package probe
import (
"embed"
"errors"
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
var errInvalid = errors.New("invalid")
// RemediationEffort indicates the estimated effort necessary to remediate a finding.
type RemediationEffort int
const (
// RemediationEffortNone indicates a no remediation effort.
RemediationEffortNone RemediationEffort = iota
// RemediationEffortLow indicates a low remediation effort.
RemediationEffortLow
// RemediationEffortMedium indicates a medium remediation effort.
RemediationEffortMedium
// RemediationEffortHigh indicates a high remediation effort.
RemediationEffortHigh
)
// Remediation represents the remediation for a finding.
type Remediation struct {
// Patch for machines.
Patch *string `json:"patch,omitempty"`
// Text for humans.
Text string `json:"text"`
// Text in markdown format for humans.
Markdown string `json:"markdown"`
// Effort to remediate.
Effort RemediationEffort `json:"effort"`
}
// nolint: govet
type yamlRemediation struct {
Text []string `yaml:"text"`
Markdown []string `yaml:"markdown"`
Effort RemediationEffort `yaml:"effort"`
}
// nolint: govet
type yamlProbe struct {
ID string `yaml:"id"`
Short string `yaml:"short"`
Motivation string `yaml:"motivation"`
Implementation string `yaml:"implementation"`
Remediation yamlRemediation `yaml:"remediation"`
}
// nolint: govet
type Probe struct {
ID string
Short string
Motivation string
Implementation string
Remediation *Remediation
}
// FromBytes creates a probe from a file.
func FromBytes(content []byte, probeID string) (*Probe, error) {
r, err := parseFromYAML(content)
if err != nil {
return nil, err
}
if err := validate(r, probeID); err != nil {
return nil, err
}
return &Probe{
ID: r.ID,
Short: r.Short,
Motivation: r.Motivation,
Implementation: r.Implementation,
Remediation: &Remediation{
Text: strings.Join(r.Remediation.Text, "\n"),
Markdown: strings.Join(r.Remediation.Markdown, "\n"),
Effort: r.Remediation.Effort,
},
}, nil
}
// New create a new probe.
func New(loc embed.FS, probeID string) (*Probe, error) {
content, err := loc.ReadFile("def.yml")
if err != nil {
return nil, fmt.Errorf("%w", err)
}
return FromBytes(content, probeID)
}
func validate(r *yamlProbe, probeID string) error {
if err := validateID(r.ID, probeID); err != nil {
return err
}
if err := validateRemediation(r.Remediation); err != nil {
return err
}
return nil
}
func validateID(actual, expected string) error {
if actual != expected {
return fmt.Errorf("%w: ID: read '%v', expected '%v'", errInvalid,
actual, expected)
}
return nil
}
func validateRemediation(r yamlRemediation) error {
switch r.Effort {
case RemediationEffortHigh, RemediationEffortMedium, RemediationEffortLow:
return nil
default:
return fmt.Errorf("%w: %v", errInvalid, fmt.Sprintf("remediation '%v'", r))
}
}
func parseFromYAML(content []byte) (*yamlProbe, error) {
r := yamlProbe{}
err := yaml.Unmarshal(content, &r)
if err != nil {
return nil, fmt.Errorf("%w: %v", errInvalid, err)
}
return &r, nil
}
// UnmarshalYAML is a custom unmarshalling function
// to transform the string into an enum.
func (r *RemediationEffort) UnmarshalYAML(n *yaml.Node) error {
var str string
if err := n.Decode(&str); err != nil {
return fmt.Errorf("%w: %v", errInvalid, err)
}
// nolint:goconst
switch n.Value {
case "Low":
*r = RemediationEffortLow
case "Medium":
*r = RemediationEffortMedium
case "High":
*r = RemediationEffortHigh
default:
return fmt.Errorf("%w: effort:%q", errInvalid, str)
}
return nil
}
// String stringifies the enum.
func (r *RemediationEffort) String() string {
switch *r {
case RemediationEffortLow:
return "Low"
case RemediationEffortMedium:
return "Medium"
case RemediationEffortHigh:
return "High"
default:
return ""
}
}

109
finding/probe/probe_test.go Normal file
View File

@ -0,0 +1,109 @@
// 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.
package probe
import (
"errors"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
func errCmp(e1, e2 error) bool {
return errors.Is(e1, e2) || errors.Is(e2, e1)
}
func Test_FromBytes(t *testing.T) {
t.Parallel()
// nolint: govet
tests := []struct {
name string
id string
path string
err error
probe *Probe
}{
{
name: "all fields set",
id: "all-fields",
path: "testdata/all-fields.yml",
probe: &Probe{
ID: "all-fields",
Short: "short description",
Implementation: "impl1 impl2\n",
Motivation: "mot1 mot2\n",
Remediation: &Remediation{
Text: "step1\nstep2 https://www.google.com/something",
Markdown: "step1\nstep2 [google.com](https://www.google.com/something)",
Effort: RemediationEffortLow,
},
},
},
{
name: "mismatch probe ID",
id: "mismatch-id",
path: "testdata/all-fields.yml",
probe: &Probe{
ID: "all-fields",
Short: "short description",
Implementation: "impl1 impl2\n",
Motivation: "mot1 mot2\n",
Remediation: &Remediation{
Text: "step1\nstep2 https://www.google.com/something",
Markdown: "step1\nstep2 [google.com](https://www.google.com/something)",
Effort: RemediationEffortLow,
},
},
err: errInvalid,
},
{
name: "missing id",
id: "missing-id",
path: "testdata/missing-id.yml",
err: errInvalid,
},
{
name: "invalid effort",
id: "invalid-effort",
path: "testdata/invalid-effort.yml",
err: errInvalid,
},
}
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()
content, err := os.ReadFile(tt.path)
if err != nil {
t.Fatalf(err.Error())
}
r, err := FromBytes(content, tt.id)
if err != nil || tt.err != nil {
if !errCmp(err, tt.err) {
t.Fatalf("unexpected error: %v", cmp.Diff(err, tt.err, cmpopts.EquateErrors()))
}
return
}
if diff := cmp.Diff(*tt.probe, *r); diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
})
}
}

16
finding/probe/testdata/all-fields.yml vendored Normal file
View File

@ -0,0 +1,16 @@
id: all-fields
short: short description
motivation: >
mot1
mot2
implementation: >
impl1
impl2
remediation:
effort: Low
text:
- step1
- step2 https://www.google.com/something
markdown:
- step1
- step2 [google.com](https://www.google.com/something)

View File

@ -1,14 +1,13 @@
id: invalid-effort
short: short description
desc: description
motivation: >
line1
line2
implementation: >
line1
line2
risk: High
remediation:
effort: Low
effort: invalid
text:
- step1
- step2 https://www.google.com/something

View File

@ -1,12 +1,10 @@
short: short description
desc: description
motivation: >
line1
line2
implementation: >
line1
line2
risk: Low
remediation:
effort: Low
text:

View File

@ -1,12 +1,11 @@
id: effort-high
short: short description
desc: description
motivation: >
line1
line2
implementation: >
line1
line2
risk: High
remediation:
effort: High
text:

View File

@ -1,12 +1,11 @@
id: effort-low
short: short description
desc: description
motivation: >
line1
line2
implementation: >
line1
line2
risk: High
remediation:
effort: Low
text:

View File

@ -1,17 +1,16 @@
id: metadata-variables
short: short description
desc: description
motivation: >
line1
line2
implementation: >
line1
line2
risk: High
remediation:
effort: Low
text:
- step1
- step2 google.com/${{ repo }}@${{ branch }}
- step2 google.com/${{ metadata.repo }}@${{ metadata.branch }}
markdown:
- step1
- step2 [google.com/${{ repo }}@${{ branch }}](google.com/${{ repo }}@${{ branch }})
- step2 [google.com/${{ metadata.repo }}@${{ metadata.branch }}](google.com/${{ metadata.repo }}@${{ metadata.branch }})

View File

@ -50,10 +50,10 @@ func nonStructuredResultString(d *checker.CheckDetail) string {
func structuredResultString(d *checker.CheckDetail) string {
var sb strings.Builder
f := d.Msg.Finding
sb.WriteString(fmt.Sprintf("%s: %s severity: %s", typeToString(d.Type), f.Risk.String(), f.Message))
sb.WriteString(fmt.Sprintf("%s: %s", typeToString(d.Type), f.Message))
if f.Location != nil {
sb.WriteString(fmt.Sprintf(": %s", f.Location.Value))
sb.WriteString(fmt.Sprintf(": %s", f.Location.Path))
if f.Location.LineStart != nil {
sb.WriteString(fmt.Sprintf(":%d", *f.Location.LineStart))
}

View File

@ -209,7 +209,7 @@ func generateDefaultConfig(risk string) string {
func getPath(d *checker.CheckDetail) string {
f := d.Msg.Finding
if f != nil && f.Location != nil {
return f.Location.Value
return f.Location.Path
}
return d.Msg.Path
}