diff --git a/finding/probe/probe.go b/finding/probe/probe.go new file mode 100644 index 00000000000..bc027d4f6f0 --- /dev/null +++ b/finding/probe/probe.go @@ -0,0 +1,192 @@ +// 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 ( + "bytes" + "embed" + "errors" + "fmt" + "io/fs" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// 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 jsonRemediation struct { + Text []string `yaml:"text"` + Markdown []string `yaml:"markdown"` + Effort RemediationEffort `yaml:"effort"` +} + +// nolint: govet +type jsonProbe struct { + ID string `yaml:"id"` + Short string `yaml:"short"` + Desc string `yaml:"desc"` + Motivation string `yaml:"motivation"` + Implementation string `yaml:"implementation"` + Remediation jsonRemediation `yaml:"remediation"` +} + +// nolint: govet +type Probe struct { + Name string + Short string + Motivation string + Implementation string + Remediation *Remediation +} + +var errInvalid = errors.New("invalid") + +func fromFile(file fs.File, probeID string) (*Probe, error) { + buf := new(bytes.Buffer) + _, err := buf.ReadFrom(file) + if err != nil { + return nil, fmt.Errorf("%w: %v", errInvalid, err) + } + r, err := parseFromJSON(buf.Bytes()) + if err != nil { + return nil, err + } + + if err := validate(r, probeID); err != nil { + return nil, err + } + + return &Probe{ + Name: 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 rule. +func New(loc embed.FS, probeID string) (*Probe, error) { + file, err := os.Open("def.yml") + if err != nil { + return nil, fmt.Errorf("%w", err) + } + defer file.Close() + return fromFile(file, probeID) +} + +func validate(r *jsonProbe, probeID string) error { + if err := validateID(r.ID, probeID); err != nil { + return fmt.Errorf("%w: %v", errInvalid, err) + } + if err := validateRemediation(r.Remediation); err != nil { + return fmt.Errorf("%w: %v", errInvalid, err) + } + return nil +} + +func validateID(actual, expected string) error { + if actual != expected { + return fmt.Errorf("%w: read '%v', expected '%v'", errInvalid, + actual, expected) + } + return nil +} + +func validateRemediation(r jsonRemediation) error { + switch r.Effort { + case RemediationEffortHigh, RemediationEffortMedium, RemediationEffortLow: + return nil + default: + return fmt.Errorf("%w: %v", errInvalid, fmt.Sprintf("remediation '%v'", r)) + } +} + +func parseFromJSON(content []byte) (*jsonProbe, error) { + r := jsonProbe{} + + 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: %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 "" + } +} diff --git a/finding/probe/probe_test.go b/finding/probe/probe_test.go new file mode 100644 index 00000000000..5fb8ec9bc4c --- /dev/null +++ b/finding/probe/probe_test.go @@ -0,0 +1,111 @@ +// 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_New(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{ + Name: "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{ + Name: "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: "invalid risk", + id: "invalid-risk", + path: "testdata/invalid-risk.yml", + err: errInvalid, + }, + { + name: "invalid effort", + id: "invalid-effort", + path: "testdata/invalid-effort.yml", + err: errInvalid, + }, + // TODO: mismatch id + } + 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() + + file, err := os.Open(tt.path) + if err != nil { + panic(err) + } + defer file.Close() + + r, err := fromFile(file, 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) + } + }) + } +} diff --git a/finding/probe/testdata/all-fields.yml b/finding/probe/testdata/all-fields.yml new file mode 100644 index 00000000000..7149af0bb90 --- /dev/null +++ b/finding/probe/testdata/all-fields.yml @@ -0,0 +1,17 @@ +id: all-fields +short: short description +motivation: > + mot1 + mot2 +implementation: > + impl1 + impl2 +risk: High +remediation: + effort: Low + text: + - step1 + - step2 https://www.google.com/something + markdown: + - step1 + - step2 [google.com](https://www.google.com/something) diff --git a/finding/probe/testdata/invalid-effort.yml b/finding/probe/testdata/invalid-effort.yml new file mode 100644 index 00000000000..58ad228153b --- /dev/null +++ b/finding/probe/testdata/invalid-effort.yml @@ -0,0 +1,17 @@ +short: short description +desc: description +motivation: > + line1 + line2 +implementation: > + line1 + line2 +risk: High +remediation: + effort: invalid + text: + - step1 + - step2 https://www.google.com/something + markdown: + - step1 + - step2 [google.com](https://www.google.com/something) diff --git a/finding/probe/testdata/invalid-risk.yml b/finding/probe/testdata/invalid-risk.yml new file mode 100644 index 00000000000..0f8e601806b --- /dev/null +++ b/finding/probe/testdata/invalid-risk.yml @@ -0,0 +1,17 @@ +short: short description +desc: description +motivation: > + line1 + line2 +implementation: > + line1 + line2 +risk: invalid +remediation: + effort: Low + text: + - step1 + - step2 https://www.google.com/something + markdown: + - step1 + - step2 [google.com](https://www.google.com/something)