From b5196b658c01a68109d5455d3a53fdb769a95a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Prpi=C4=8D?= Date: Wed, 6 Sep 2023 10:54:54 -0400 Subject: [PATCH] Initial commit --- .gitignore | 2 + .goreleaser.yaml | 46 +++++++++++ Makefile | 5 ++ cmd/cvelint/main.go | 132 +++++++++++++++++++++++++++++ go.mod | 16 ++++ go.sum | 17 ++++ internal/linter.go | 151 ++++++++++++++++++++++++++++++++++ internal/rules/affects.go | 47 +++++++++++ internal/rules/common.go | 11 +++ internal/rules/cvss.go | 42 ++++++++++ internal/rules/description.go | 64 ++++++++++++++ internal/rules/references.go | 47 +++++++++++ internal/ruleset.go | 51 ++++++++++++ 13 files changed, 631 insertions(+) create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Makefile create mode 100644 cmd/cvelint/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/linter.go create mode 100644 internal/rules/affects.go create mode 100644 internal/rules/common.go create mode 100644 internal/rules/cvss.go create mode 100644 internal/rules/description.go create mode 100644 internal/rules/references.go create mode 100644 internal/ruleset.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5d8f72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/ +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..8fe31b1 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,46 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +before: + hooks: + # You may remove this if you don't use go modules. + - go mod tidy + # you may remove this if you don't need go generate + - go generate ./... +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + main: cmd/cvelint/main.go + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of uname. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ incpatch .Version }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + +# The lines beneath this are called `modelines`. See `:help modeline` +# Feel free to remove those if you don't want/use them. +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=cnqoj diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..091afc1 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +build: + go build -o bin/cvelint cmd/cvelint/main.go + +clean: + /bin/rm -f bin/cvelint diff --git a/cmd/cvelint/main.go b/cmd/cvelint/main.go new file mode 100644 index 0000000..99173e6 --- /dev/null +++ b/cmd/cvelint/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "flag" + "fmt" + "github.com/mprpic/cvelint/internal" + "io/fs" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +func collectFiles(args []string) ([]string, error) { + var files []string + var dir string + if len(args) == 0 { + dir = "." + } else { + info, err := os.Stat(args[0]) + if err != nil { + return files, err + } + if info.IsDir() { + dir = args[0] + } else { + if filepath.Ext(info.Name()) != ".json" { + return files, fmt.Errorf("ERROR: \"%s\" is not a JSON file", args[0]) + } + files = append(files, args[0]) + return files, nil + } + } + err := filepath.WalkDir(dir, func(f string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && filepath.Ext(d.Name()) == ".json" { + files = append(files, f) + } + return nil + }) + return files, err +} + +func main() { + log.SetFlags(0) + + flag.Usage = func() { + w := flag.CommandLine.Output() + fmt.Fprintf(w, "Usage of %s: [OPTION] [DIRECTORY|FILE]\n", os.Args[0]) + flag.PrintDefaults() + } + + var format string + flag.StringVar(&format, "format", "text", "Output format: text, json, csv") + + var cna string + flag.StringVar(&cna, "cna", "", "Show results for CVE records of a specific CNA") + + var selectRules string + flag.StringVar(&selectRules, "select", "", "Comma-separated list of rule codes to enable (default: all)") + + var ignoreRules string + flag.StringVar(&ignoreRules, "ignore", "", "Comma-separated list of rule codes to disable (default: none)") + + var printRules bool + flag.BoolVar(&printRules, "show-rules", false, "Print list of available validation rules") + + flag.Parse() + args := flag.Args() + + if printRules { + var codes []string + for code := range internal.RuleSet { + codes = append(codes, code) + } + sort.Strings(codes) + for _, code := range codes { + fmt.Printf("%s: %s\n", code, internal.RuleSet[code].Description) + } + os.Exit(0) + } + + if len(args) != 1 { + fmt.Println("ERROR: Incorrect number of arguments") + flag.Usage() + os.Exit(1) + } + + files, err := collectFiles(args) + if err != nil { + log.Fatalf("ERROR: %s", err) + } + if len(files) == 0 { + log.Fatal("ERROR: no CVE record JSON files found") + } + + var ruleCodes = make(map[string]struct{}) + if selectRules != "" { + // Select unique specified rule codes + for _, ruleCode := range strings.Split(selectRules, ",") { + ruleCodes[ruleCode] = struct{}{} + } + } else { + // Select all rule codes + for ruleCode, _ := range internal.RuleSet { + ruleCodes[ruleCode] = struct{}{} + } + } + // Remove ignored rule codes + for _, ruleCode := range strings.Split(ignoreRules, ",") { + delete(ruleCodes, ruleCode) + } + + // Collect Rules from specified rule codes + var rules []internal.Rule + for ruleCode, _ := range ruleCodes { + rule, ok := internal.RuleSet[ruleCode] + if !ok { + log.Fatalf("ERROR: unknown rule selected: %s", ruleCode) + } else { + rules = append(rules, rule) + } + } + + linter := internal.Linter{Timestamp: time.Now().UTC(), FileInput: &files} + linter.Run(&rules, cna) + linter.Print(format) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bfff5dd --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/mprpic/cvelint + +go 1.20 + +require ( + github.com/fatih/color v1.15.0 + github.com/tidwall/gjson v1.16.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/sys v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5d9998c --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= +github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/linter.go b/internal/linter.go new file mode 100644 index 0000000..751f484 --- /dev/null +++ b/internal/linter.go @@ -0,0 +1,151 @@ +package internal + +import ( + "encoding/json" + "fmt" + "github.com/fatih/color" + "github.com/mprpic/cvelint/internal/rules" + "github.com/tidwall/gjson" + "log" + "os" + "sort" + "strconv" + "strings" + "time" +) + +type Linter struct { + Timestamp time.Time + FileInput *[]string + FilesChecked int + Results []LintResult +} + +type LintResult struct { + File string + CveId string + Cna string + Error rules.ValidationError + Rule +} + +func (l *Linter) Run(rules *[]Rule, cna string) { + checkedFiles := 0 + for _, file := range *l.FileInput { + cveId := strings.TrimSuffix(file[strings.LastIndex(file, "/")+1:], ".json") + jsonBytes, err := os.ReadFile(file) + // Convert to string because gjson.Result.Path does not accept []byte + json := string(jsonBytes) + if err != nil { + log.Fatalf("ERROR: failed to read JSON file %v", err) + } + if !gjson.Valid(json) { + log.Fatalf("ERROR: invalid JSON file %s", file) + } + recordCna := gjson.Get(json, "cveMetadata.assignerShortName").String() + if recordCna == "" { + // Not a CVE v5 JSON record, skip. + continue + } + if cna != "" && cna != recordCna { + continue + } + for _, rule := range *rules { + errors := rule.CheckFunc(&json) + for _, e := range errors { + l.Results = append(l.Results, LintResult{ + File: file, + CveId: cveId, + Cna: recordCna, + Error: e, + Rule: rule, + }) + } + } + checkedFiles++ + + sort.Slice(l.Results, func(i, j int) bool { + // Sort results alphanumerically by CVE ID (starting from newest) + a := strings.Split(l.Results[i].CveId, "-") // CVE-2020-0001 -> [CVE 2020 0001] + b := strings.Split(l.Results[j].CveId, "-") + i, _ = strconv.Atoi(strings.Join(a[1:], "")) // 20200001 + j, _ = strconv.Atoi(strings.Join(b[1:], "")) + return i > j + }) + } + l.FilesChecked = checkedFiles +} + +func (l *Linter) Print(format string) { + switch format { + case "text": + fmt.Printf("") + fmt.Printf("Collected %d file", len(*l.FileInput)) + if len(*l.FileInput) != 1 { + fmt.Print("s") + } + fmt.Printf("; checked %d file", l.FilesChecked) + if l.FilesChecked != 1 { + fmt.Println("s.") + } else { + fmt.Println(".") + } + + bold := color.New(color.Bold).Add(color.Underline) + red := color.New(color.FgRed) + lastCve := "" + for _, r := range l.Results { + if lastCve != r.CveId { + fmt.Println() + bold.Print(r.CveId) + fmt.Printf(" (%s) -- %s\n", r.Cna, r.File) + } + lastCve = r.CveId + fmt.Print(" ") + red.Printf("%s ", r.Code) + fmt.Println(r.Error.Text) + } + + fmt.Printf("\nFound %d error", len(l.Results)) + if len(l.Results) != 1 { + fmt.Print("s.\n") + } else { + fmt.Print(".\n") + } + + case "json": + fmt.Println("{") + fmt.Printf(` "generatedAt": "%s",`+"\n", l.Timestamp.Format(time.RFC3339)) + fmt.Println(` "results": [`) + for i, r := range l.Results { + fmt.Println(" {") + errorJson, _ := json.Marshal(r.Error.Text) + fmt.Printf(` "cve": "%s",`+"\n", r.CveId) + fmt.Printf(` "cna": "%s",`+"\n", r.Cna) + fmt.Printf(` "file": "%s",`+"\n", r.File) + fmt.Printf(` "ruleName": "%s",`+"\n", r.Rule.Name) + fmt.Printf(` "errorCode": "%s",`+"\n", r.Rule.Code) + fmt.Printf(` "errorPath": "%s",`+"\n", r.Error.JsonPath) + fmt.Printf(` "errorText": %s`+"\n", errorJson) + fmt.Print(" }") + if i+1 != len(l.Results) { + fmt.Print(",") + } + fmt.Println() + } + fmt.Println(" ]") + fmt.Println("}") + + case "csv": + if len(l.Results) == 0 { + return + } + fmt.Println("CVE,CNA,File,RuleName,ErrorCode,ErrorText") + for _, r := range l.Results { + fmt.Printf("%s,%s,%s,%s,%s,%s,%s\n", r.CveId, r.Cna, r.File, r.Rule.Name, r.Rule.Code, r.Error.JsonPath, r.Error.Text) + } + + default: + log.Fatal("ERROR: Invalid output format, must be one of: text, json, csv") + } +} diff --git a/internal/rules/affects.go b/internal/rules/affects.go new file mode 100644 index 0000000..8f10b07 --- /dev/null +++ b/internal/rules/affects.go @@ -0,0 +1,47 @@ +package rules + +import ( + "github.com/tidwall/gjson" +) + +func CheckAffectedProduct(json *string) []ValidationError { + if gjson.Get(*json, `cveMetadata.state`).String() != "PUBLISHED" { + // REJECTED records do not list affected products + return nil + } + var errors []ValidationError + // Check if a product version exists that is marked affected or unknown + data := gjson.Get(*json, `containers.cna.affected.#.versions.#.status`) + affectedProductFound := false + for _, affect := range data.Array() { + for _, status := range affect.Array() { + status := status.String() + if status == "affected" || status == "unknown" { + affectedProductFound = true + break + } + } + if affectedProductFound { + break + } + } + // Check if a defaultStatus exists that is set to affected or unknown + if !affectedProductFound { + data := gjson.Get(*json, `containers.cna.affected.#.defaultStatus`) + data.ForEach(func(key, value gjson.Result) bool { + status := value.String() + if status == "affected" || status == "unknown" { + affectedProductFound = true + return false // stop iterating + } + return true + }) + } + if !affectedProductFound { + errors = append(errors, ValidationError{ + Text: "No affected product found", + JsonPath: "containers.cna.affected", + }) + } + return errors +} diff --git a/internal/rules/common.go b/internal/rules/common.go new file mode 100644 index 0000000..9251ad1 --- /dev/null +++ b/internal/rules/common.go @@ -0,0 +1,11 @@ +package rules + +const ( + CveRecordStatePublished = "PUBLISHED" + CveRecordStateRejected = "REJECTED" +) + +type ValidationError struct { + Text string + JsonPath string +} diff --git a/internal/rules/cvss.go b/internal/rules/cvss.go new file mode 100644 index 0000000..b79c80d --- /dev/null +++ b/internal/rules/cvss.go @@ -0,0 +1,42 @@ +package rules + +import ( + "fmt" + "github.com/tidwall/gjson" + "strings" +) + +func CheckCvssV3BaseSeverity(json *string) []ValidationError { + var errors []ValidationError + d := gjson.Get(*json, "containers.cna.metrics.#.cvssV3*") + d.ForEach(func(key, value gjson.Result) bool { + score := value.Get("baseScore").Float() + severity := strings.ToLower(value.Get("baseSeverity").String()) + correctSeverity := computeSeverity(score) + if severity != correctSeverity { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf(`Incorrect CVSS v3 severity: "%s" (should be "%s")`, severity, correctSeverity), + JsonPath: value.Path(*json), + }) + } + return true + }) + return errors +} + +func computeSeverity(score float64) string { + // Severity rating scale is the same for both versions of CVSS v3: + // - https://www.first.org/cvss/v3.1/specification-document#Qualitative-Severity-Rating-Scale + // - https://www.first.org/cvss/v3.0/specification-document#Qualitative-Severity-Rating-Scale + if score == 0.0 { + return "none" + } else if score <= 3.9 { + return "low" + } else if score <= 6.9 { + return "medium" + } else if score <= 8.9 { + return "high" + } else { + return "critical" + } +} diff --git a/internal/rules/description.go b/internal/rules/description.go new file mode 100644 index 0000000..36cfa51 --- /dev/null +++ b/internal/rules/description.go @@ -0,0 +1,64 @@ +package rules + +import ( + "fmt" + "github.com/tidwall/gjson" + "strings" + "unicode/utf8" +) + +const minDescriptionTextLength = 10 + +func CheckLength(json *string) []ValidationError { + var errors []ValidationError + + state := gjson.Get(*json, `cveMetadata.state`).String() + var data gjson.Result + if state == CveRecordStatePublished { + data = gjson.Get(*json, `containers.cna.descriptions.#(lang=="en")#`) + } else if state == CveRecordStateRejected { + data = gjson.Get(*json, `containers.cna.rejectedReasons.#(lang=="en")#`) + } + + enDescCount := 0 + data.ForEach(func(key, value gjson.Result) bool { + text := value.Get("value").String() + if utf8.RuneCountInString(text) < minDescriptionTextLength { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Description too short: %s", text), + JsonPath: value.Path(*json), + }) + } + enDescCount += 1 + return true + }) + if enDescCount > 1 { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("More than one en-US description present"), + JsonPath: data.Path(*json), + }) + } + return errors +} + +func CheckLeadingTrailingSpace(json *string) []ValidationError { + if gjson.Get(*json, `cveMetadata.state`).String() != "PUBLISHED" { + // REJECTED records do not require a description + return nil + } + var errors []ValidationError + + // Find all descriptions with lang "en" + d := gjson.Get(*json, `containers.cna.descriptions.#.value`) + d.ForEach(func(key, value gjson.Result) bool { + text := value.String() + if len(strings.TrimSpace(text)) != len(text) { + errors = append(errors, ValidationError{ + Text: "Trailing or leading whitespace in description", + JsonPath: value.Path(*json), + }) + } + return true + }) + return errors +} diff --git a/internal/rules/references.go b/internal/rules/references.go new file mode 100644 index 0000000..04f165c --- /dev/null +++ b/internal/rules/references.go @@ -0,0 +1,47 @@ +package rules + +import ( + "fmt" + "github.com/tidwall/gjson" + "regexp" +) + +var refUrlRe = regexp.MustCompile(`^(ftps?|https?)://.*`) + +func CheckRefProtocol(json *string) []ValidationError { + // Based on CNA rule 8.3: + // https://www.cve.org/ResourcesSupport/AllResources/CNARules#section_8-3_cve_record_reference_requirements + var errors []ValidationError + data := gjson.Get(*json, `containers.cna.references.#.url`) + data.ForEach(func(key, value gjson.Result) bool { + url := value.String() + if !refUrlRe.MatchString(url) { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Invalid reference URL: %s", url), + JsonPath: value.Path(*json), + }) + } + return true + }) + return errors +} + +func DuplicateRefs(json *string) []ValidationError { + var errors []ValidationError + data := gjson.GetMany(*json, `containers.cna.references.#.url`, `containers.adp.references.#.url`) + var urls = make(map[string]int) + for _, v := range data { + v.ForEach(func(key, value gjson.Result) bool { + urls[value.String()]++ + return true + }) + } + for url, count := range urls { + if count > 1 { + errors = append(errors, ValidationError{ + Text: fmt.Sprintf("Duplicate reference URL: %s", url), + }) + } + } + return errors +} diff --git a/internal/ruleset.go b/internal/ruleset.go new file mode 100644 index 0000000..d49d7da --- /dev/null +++ b/internal/ruleset.go @@ -0,0 +1,51 @@ +package internal + +import ( + "github.com/mprpic/cvelint/internal/rules" +) + +type Rule struct { + Code string + Name string + Description string + CheckFunc func(*string) []rules.ValidationError +} + +var RuleSet = map[string]Rule{ + "E001": { + Code: "E001", + Name: "check-reference-url-protocol", + Description: "Reference URLs use allowed protocols (ftp(s)/http(s))", + CheckFunc: rules.CheckRefProtocol, + }, + "E002": { + Code: "E002", + Name: "check-duplicate-reference-url", + Description: "CVE record does not contain duplicate reference URLs", + CheckFunc: rules.DuplicateRefs, + }, + "E003": { + Code: "E003", + Name: "check-description-length", + Description: "One en-US description of at least 10 characters is present in the CNA container", + CheckFunc: rules.CheckLength, + }, + "E004": { + Code: "E004", + Name: "check-leading-trailing-space", + Description: "CNA container descriptions do not have leading or trailing whitespace", + CheckFunc: rules.CheckLeadingTrailingSpace, + }, + "E005": { + Code: "E005", + Name: "check-cvss3-base-severity", + Description: "CVSSv3 base severity matches the base score", + CheckFunc: rules.CheckCvssV3BaseSeverity, + }, + "E006": { + Code: "E006", + Name: "check-affected-product-present", + Description: "One affected/unknown product is present in CNA container", + CheckFunc: rules.CheckAffectedProduct, + }, +}