diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c8606..bf3c9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [UNRELEASED] +## [0.8.0] - 2024-10-23 + +* Fixed a bug where validation failures were not resulting in a non-zero exit code +* Renamed the --file / -f CLI argument to --config / -f for consistency with other tools +* improvements to validate output + These "improvements" are short term hacks. There is a need for a fundamental + overhaul of how output is generated to improve usability +* Implemented code coverage support. +* Updated the docs to reflect the removal of the --all flag + +## [0.7.6] - 2024-09-08 + +### Fixed + +- Crash when running `gatecheck bundle add` with no tags + ## [0.7.5] - 2024-06-18 ### Fixed diff --git a/cmd/cli-config.go b/cmd/cli-config.go index a4e6e56..3d265bb 100644 --- a/cmd/cli-config.go +++ b/cmd/cli-config.go @@ -45,7 +45,7 @@ var RuntimeConfig = metaConfig{ BundleTag: configkit.MetaField{ FieldName: "BundleTag", EnvKey: "GATECHECK_BUNDLE_TAG", - DefaultValue: "", + DefaultValue: []string{}, FlagValueP: new([]string), EnvToValueFunc: func(s string) any { return strings.Split(s, ",") @@ -165,7 +165,7 @@ var RuntimeConfig = metaConfig{ CobraSetupFunc: func(f configkit.MetaField, cmd *cobra.Command) { valueP := f.FlagValueP.(*string) usage := f.Metadata[metadataFlagUsage] - cmd.PersistentFlags().StringVarP(valueP, "file", "f", "", usage) + cmd.PersistentFlags().StringVarP(valueP, "config", "f", "", usage) }, Metadata: map[string]string{ metadataFlagUsage: "a validation configuration file", diff --git a/cmd/validate.go b/cmd/validate.go index 3bb9e51..73a3161 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -69,7 +69,7 @@ var validateCmd = &cobra.Command{ return nil } - return nil + return err }, } diff --git a/demos/bundle.tape b/demos/bundle.tape index f1cfdaa..2e16b8c 100644 --- a/demos/bundle.tape +++ b/demos/bundle.tape @@ -10,7 +10,7 @@ Set Width 1800 Output dist/gatecheck-bundle.gif -Type "gatecheck ls --all grype-report.json | less" +Type "gatecheck ls grype-report.json | less" Sleep 1 Enter Sleep 5 diff --git a/demos/list.tape b/demos/list.tape index 24d4abb..c5e7bac 100644 --- a/demos/list.tape +++ b/demos/list.tape @@ -10,7 +10,7 @@ Set Width 1600 Output dist/gatecheck-list.gif -Type "grype ubuntu:latest -o json | gatecheck ls --all -i grype | less" +Type "grype ubuntu:latest -o json | gatecheck ls -i grype | less" Sleep 1 Enter diff --git a/demos/validate.tape b/demos/validate.tape index 247a95f..f07f605 100644 --- a/demos/validate.tape +++ b/demos/validate.tape @@ -10,7 +10,7 @@ Set Width 1700 Output dist/gatecheck-validate.gif -Type "gatecheck ls --all grype-report.json | less" +Type "gatecheck ls grype-report.json | less" Sleep 1 Enter Sleep 5 diff --git a/docs/assets/screenshot-grype-list-all.png b/docs/assets/screenshot-grype-list-all.png deleted file mode 100644 index cc35243..0000000 Binary files a/docs/assets/screenshot-grype-list-all.png and /dev/null differ diff --git a/docs/list-reports.md b/docs/list-reports.md index 42ac742..cc537bf 100644 --- a/docs/list-reports.md +++ b/docs/list-reports.md @@ -22,11 +22,3 @@ gatecheck ls grype-scan-report.json ``` ![Screenshot Example List](assets/screenshot-grype-list.png) - -Using the `--all` or `-a` flag will do a full listing, cross-referencing with FIRST EPSS API - -```shell -grype bkimminich/juice-shop:latest -o json | gatecheck ls --all -i grype -``` - -![Screenshot Example List All](assets/screenshot-grype-list-all.png) diff --git a/go.mod b/go.mod index 19954b1..f211af6 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( ) require ( + github.com/easy-up/go-coverage v0.0.0-20241018034313-3de592d59a78 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 46b7c8b..6e84ad7 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/easy-up/go-coverage v0.0.0-20241018034313-3de592d59a78 h1:e2x+TfIgebN3zfr8wGqAYI9lK4ql7Rut6OTEhBmJr5k= +github.com/easy-up/go-coverage v0.0.0-20241018034313-3de592d59a78/go.mod h1:fsSINOc273zPnsBaKNjNffZXZpicAArpv/cTiFYgPys= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= diff --git a/pkg/archive/bundle.go b/pkg/archive/bundle.go index 983dde0..716c4e2 100644 --- a/pkg/archive/bundle.go +++ b/pkg/archive/bundle.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/olekukonko/tablewriter" "io" "log/slog" "os" @@ -159,7 +160,10 @@ func (b *Bundle) Content() string { sort.Sort(matrix) buf := new(bytes.Buffer) header := []string{"Label", "Digest", "Tags", "Size"} - matrix.Table(buf, header).Render() + table := tablewriter.NewWriter(buf) + table.SetHeader(header) + matrix.Table(table) + table.Render() return buf.String() } diff --git a/pkg/artifacts/lcov.go b/pkg/artifacts/lcov.go new file mode 100644 index 0000000..6abb15d --- /dev/null +++ b/pkg/artifacts/lcov.go @@ -0,0 +1,42 @@ +package artifacts + +import ( + "errors" + "github.com/easy-up/go-coverage" + "log/slog" + "strings" +) + +func example() (coverage.Report, error) { + lcovParser := coverage.New(coverage.LCOV) + report, err := lcovParser.Parse("./path/to/lcov.info") + if err != nil { + // Handle error + return coverage.Report{}, err + } + // Use the parsed report + return report, nil +} + +func IsCoverageReport(inputFilename string) bool { + return strings.Contains(inputFilename, "lcov") || + strings.HasSuffix(inputFilename, ".info") || + strings.Contains(inputFilename, "clover") || + strings.Contains(inputFilename, "cobertura") || + strings.Contains(inputFilename, "coverage") +} + +func GetCoverageMode(inputFilename string) (coverage.CoverageMode, error) { + var coverageFormat coverage.CoverageMode + if strings.Contains(inputFilename, "lcov") || strings.HasSuffix(inputFilename, ".info") { + coverageFormat = coverage.LCOV + } else if strings.Contains(inputFilename, "clover") { + coverageFormat = coverage.CLOVER + } else if strings.HasSuffix(inputFilename, ".xml") { + coverageFormat = coverage.COBERTURA + } else { + slog.Error("unsupported coverage file type, cannot be determined from filename", "filename", inputFilename) + return "", errors.New("failed to list coverage content") + } + return coverageFormat, nil +} diff --git a/pkg/format/matrix.go b/pkg/format/matrix.go index ef1288c..5b8171d 100644 --- a/pkg/format/matrix.go +++ b/pkg/format/matrix.go @@ -1,8 +1,6 @@ package format import ( - "io" - "github.com/olekukonko/tablewriter" ) @@ -28,11 +26,8 @@ func (m *SortableMatrix) Matrix() [][]string { return m.data } -func (m *SortableMatrix) Table(w io.Writer, header []string) *tablewriter.Table { - table := tablewriter.NewWriter(w) - table.SetHeader(header) +func (m *SortableMatrix) Table(table *tablewriter.Table) { table.AppendBulk(m.data) - return table } func (m *SortableMatrix) Len() int { diff --git a/pkg/gatecheck/config.go b/pkg/gatecheck/config.go index 6c26d60..e2171ee 100644 --- a/pkg/gatecheck/config.go +++ b/pkg/gatecheck/config.go @@ -28,6 +28,7 @@ type Config struct { Cyclonedx reportWithCVEs `json:"cyclonedx" toml:"cyclonedx" yaml:"cyclonedx"` Semgrep configSemgrepReport `json:"semgrep" toml:"semgrep" yaml:"semgrep"` Gitleaks configGitleaksReport `json:"gitleaks" toml:"gitleaks" yaml:"gitleaks"` + Coverage configCoverageReport `json:"coverage" toml:"coverage" yaml:"coverage"` } func (c *Config) String() string { @@ -48,6 +49,12 @@ func (c *Config) String() string { return contentBuf.String() } +type configCoverageReport struct { + LineThreshold float32 `json:"lineThreshold" toml:"lineThreshold" yaml:"lineThreshold"` + FunctionThreshold float32 `json:"functionThreshold" toml:"functionThreshold" yaml:"functionThreshold"` + BranchThreshold float32 `json:"branchThreshold" toml:"branchThreshold" yaml:"branchThreshold"` +} + type configGitleaksReport struct { LimitEnabled bool `json:"limitEnabled" toml:"limitEnabled" yaml:"limitEnabled"` } @@ -225,6 +232,11 @@ func NewDefaultConfig() *Config { Gitleaks: configGitleaksReport{ LimitEnabled: false, }, + Coverage: configCoverageReport{ + LineThreshold: 0, + FunctionThreshold: 0, + BranchThreshold: 0, + }, } } diff --git a/pkg/gatecheck/list.go b/pkg/gatecheck/list.go index 25280fb..9fefc60 100644 --- a/pkg/gatecheck/list.go +++ b/pkg/gatecheck/list.go @@ -4,10 +4,12 @@ import ( "encoding/json" "errors" "fmt" + "github.com/easy-up/go-coverage" "io" "log/slog" "os" "sort" + "strconv" "strings" "github.com/gatecheckdev/gatecheck/pkg/archive" @@ -47,7 +49,7 @@ func WithEPSS(epssFile *os.File, epssURL string) (func(*listOptions), error) { } func List(dst io.Writer, src io.Reader, inputFilename string, options ...ListOptionFunc) error { - var table *tablewriter.Table + table := tablewriter.NewWriter(dst) var err error o := &listOptions{} for _, f := range options { @@ -58,26 +60,26 @@ func List(dst io.Writer, src io.Reader, inputFilename string, options ...ListOpt case strings.Contains(inputFilename, "grype"): slog.Debug("list", "filename", inputFilename, "filetype", "grype") if o.epssData != nil { - table, err = listGrypeWithEPSS(dst, src, o.epssData) + err = listGrypeWithEPSS(table, src, o.epssData) } else { - table, err = ListGrypeReport(dst, src) + err = ListGrypeReport(table, src) } case strings.Contains(inputFilename, "cyclonedx"): slog.Debug("list", "filename", inputFilename, "filetype", "cyclonedx") if o.epssData != nil { - table, err = listCyclonedxWithEPSS(dst, src, o.epssData) + err = listCyclonedxWithEPSS(table, src, o.epssData) } else { - table, err = ListCyclonedx(dst, src) + err = ListCyclonedx(table, src) } case strings.Contains(inputFilename, "semgrep"): slog.Debug("list", "filename", inputFilename, "filetype", "semgrep") - table, err = ListSemgrep(dst, src) + err = ListSemgrep(table, src) case strings.Contains(inputFilename, "gitleaks"): slog.Debug("list", "filename", inputFilename, "filetype", "gitleaks") - table, err = listGitleaks(dst, src) + err = listGitleaks(table, src) case strings.Contains(inputFilename, "syft"): slog.Debug("list", "filename", inputFilename, "filetype", "syft") @@ -87,15 +89,19 @@ func List(dst io.Writer, src io.Reader, inputFilename string, options ...ListOpt case strings.Contains(inputFilename, "bundle") || strings.Contains(inputFilename, "gatecheck"): slog.Debug("list", "filename", inputFilename, "filetype", "bundle") bundle := archive.NewBundle() - if err := archive.UntarGzipBundle(src, bundle); err != nil { + if err = archive.UntarGzipBundle(src, bundle); err != nil { return err } - _, err := fmt.Fprintln(dst, bundle.Content()) + _, err = fmt.Fprintln(dst, bundle.Content()) return err + case artifacts.IsCoverageReport(inputFilename): + slog.Debug("list", "filename", inputFilename, "filetype", "coverage") + + err = listCoverage(table, inputFilename, src) default: slog.Error("unsupported file type, cannot be determined from filename", "filename", inputFilename) - return errors.New("Failed to list artifact content") + return errors.New("failed to list artifact content") } if err != nil { @@ -114,11 +120,35 @@ func List(dst io.Writer, src io.Reader, inputFilename string, options ...ListOpt return nil } -func ListGrypeReport(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { +func listCoverage(table *tablewriter.Table, inputFilename string, src io.Reader) error { + coverageFormat, err := artifacts.GetCoverageMode(inputFilename) + if err != nil { + return err + } + + parser := coverage.New(coverageFormat) + report, err := parser.ParseReader(src) + if err != nil { + return err + } + + header := []string{"Lines Covered", "Functions Covered", "Branches Covered"} + table.SetHeader(header) + table.Append([]string{strconv.Itoa(report.CoveredLines), strconv.Itoa(report.CoveredFunctions), strconv.Itoa(report.CoveredBranches)}) + + lineCoverageStr := fmt.Sprintf("%0.2f%%", (float32(report.CoveredLines)/float32(report.TotalLines))*100) + funcCoverageStr := fmt.Sprintf("%0.2f%%", (float32(report.CoveredFunctions)/float32(report.TotalFunctions))*100) + branchCoverageStr := fmt.Sprintf("%0.2f%%", (float32(report.CoveredBranches)/float32(report.TotalBranches))*100) + table.SetFooter([]string{lineCoverageStr, funcCoverageStr, branchCoverageStr}) + + return nil +} + +func ListGrypeReport(table *tablewriter.Table, src io.Reader) error { report := &artifacts.GrypeReportMin{} slog.Debug("decode grype report", "format", "json") if err := json.NewDecoder(src).Decode(&report); err != nil { - return nil, err + return err } catLess := format.NewCatagoricLess([]string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"}) @@ -132,7 +162,8 @@ func ListGrypeReport(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { header := []string{"Grype Severity", "Package", "Version", "Link"} - table := matrix.Table(dst, header) + table.SetHeader(header) + matrix.Table(table) if len(report.Matches) == 0 { footer := make([]string, len(header)) @@ -141,14 +172,14 @@ func ListGrypeReport(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { table.SetBorder(false) } - return table, nil + return nil } -func listGrypeWithEPSS(dst io.Writer, src io.Reader, epssData *epss.Data) (*tablewriter.Table, error) { +func listGrypeWithEPSS(table *tablewriter.Table, src io.Reader, epssData *epss.Data) error { report := &artifacts.GrypeReportMin{} slog.Debug("decode grype report", "format", "json") if err := json.NewDecoder(src).Decode(&report); err != nil { - return nil, err + return err } catLess := format.NewCatagoricLess([]string{"Critical", "High", "Medium", "Low", "Negligible", "Unknown"}) @@ -187,7 +218,8 @@ func listGrypeWithEPSS(dst io.Writer, src io.Reader, epssData *epss.Data) (*tabl sort.Sort(matrix) - table := matrix.Table(dst, header) + table.SetHeader(header) + matrix.Table(table) if len(report.Matches) == 0 { footer := make([]string, len(header)) @@ -196,14 +228,14 @@ func listGrypeWithEPSS(dst io.Writer, src io.Reader, epssData *epss.Data) (*tabl table.SetBorder(false) } - return table, nil + return nil } -func ListCyclonedx(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { +func ListCyclonedx(table *tablewriter.Table, src io.Reader) error { report := &artifacts.CyclonedxReportMin{} slog.Debug("decode cyclonedx report", "format", "json") if err := json.NewDecoder(src).Decode(&report); err != nil { - return nil, err + return err } catLess := format.NewCatagoricLess([]string{"critical", "high", "medium", "low", "none"}) @@ -223,7 +255,8 @@ func ListCyclonedx(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { sort.Sort(matrix) header := []string{"Cyclonedx CVE ID", "Severity", "Package", "Link"} - table := matrix.Table(dst, header) + table.SetHeader(header) + matrix.Table(table) if len(report.Vulnerabilities) == 0 { footer := make([]string, len(header)) @@ -232,14 +265,14 @@ func ListCyclonedx(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { table.SetBorder(false) } - return table, nil + return nil } -func listCyclonedxWithEPSS(dst io.Writer, src io.Reader, epssData *epss.Data) (*tablewriter.Table, error) { +func listCyclonedxWithEPSS(table *tablewriter.Table, src io.Reader, epssData *epss.Data) error { report := &artifacts.CyclonedxReportMin{} slog.Debug("decode grype report", "format", "json") if err := json.NewDecoder(src).Decode(&report); err != nil { - return nil, err + return err } catLess := format.NewCatagoricLess([]string{"critical", "high", "medium", "low", "info", "none", "unknown"}) @@ -271,7 +304,8 @@ func listCyclonedxWithEPSS(dst io.Writer, src io.Reader, epssData *epss.Data) (* sort.Sort(matrix) header := []string{"Cyclonedx CVE ID", "Severity", "EPSS Score", "EPSS Prctl", "affected Packages", "Link"} - table := matrix.Table(dst, header) + table.SetHeader(header) + matrix.Table(table) if len(report.Vulnerabilities) == 0 { footer := make([]string, len(header)) @@ -280,14 +314,14 @@ func listCyclonedxWithEPSS(dst io.Writer, src io.Reader, epssData *epss.Data) (* table.SetBorder(false) } - return table, nil + return nil } -func ListSemgrep(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { +func ListSemgrep(table *tablewriter.Table, src io.Reader) error { report := &artifacts.SemgrepReportMin{} if err := json.NewDecoder(src).Decode(report); err != nil { - return nil, err + return err } for _, semgrepError := range report.Errors { @@ -316,7 +350,8 @@ func ListSemgrep(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { sort.Sort(matrix) header := []string{"Semgrep Check ID", "Owasp IDs", "Severity", "Impact", "link"} - table := matrix.Table(dst, header) + table.SetHeader(header) + matrix.Table(table) if len(report.Results) == 0 { footer := make([]string, len(header)) @@ -325,17 +360,15 @@ func ListSemgrep(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { table.SetBorder(false) } - return table, nil + return nil } -func listGitleaks(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { +func listGitleaks(table *tablewriter.Table, src io.Reader) error { report := artifacts.GitLeaksReportMin{} if err := json.NewDecoder(src).Decode(&report); err != nil { - return nil, err + return err } - table := tablewriter.NewWriter(dst) - header := []string{"Gitleaks Rule ID", "File", "Commit", "Start Line"} table.SetHeader(header) for _, finding := range report { @@ -355,5 +388,5 @@ func listGitleaks(dst io.Writer, src io.Reader) (*tablewriter.Table, error) { table.SetBorder(false) } - return table, nil + return nil } diff --git a/pkg/gatecheck/validate.go b/pkg/gatecheck/validate.go index 34c8711..28a2415 100644 --- a/pkg/gatecheck/validate.go +++ b/pkg/gatecheck/validate.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "github.com/easy-up/go-coverage" "io" "log/slog" "slices" + "sort" "strings" "github.com/gatecheckdev/gatecheck/pkg/archive" @@ -23,43 +25,75 @@ func newValidationErr(details string) error { } // Validate against config thresholds -func Validate(config *Config, reportSrc io.Reader, targetfilename string, optionFuncs ...optionFunc) error { +func Validate(config *Config, reportSrc io.Reader, targetFilename string, optionFuncs ...optionFunc) error { options := defaultOptions() for _, f := range optionFuncs { f(options) } switch { - case strings.Contains(targetfilename, "grype"): - slog.Debug("validate grype report", "filename", targetfilename) + case strings.Contains(targetFilename, "grype"): + slog.Debug("validate grype report", "filename", targetFilename) return validateGrypeReportWithFetch(reportSrc, config, options) - case strings.Contains(targetfilename, "cyclonedx"): - slog.Debug("validate", "filename", targetfilename, "filetype", "cyclonedx") + case strings.Contains(targetFilename, "cyclonedx"): + slog.Debug("validate", "filename", targetFilename, "filetype", "cyclonedx") return validateCyclonedxReportWithFetch(reportSrc, config, options) - case strings.Contains(targetfilename, "semgrep"): - slog.Debug("validate", "filename", targetfilename, "filetype", "semgrep") + case strings.Contains(targetFilename, "semgrep"): + slog.Debug("validate", "filename", targetFilename, "filetype", "semgrep") return validateSemgrepReport(reportSrc, config) - case strings.Contains(targetfilename, "gitleaks"): - slog.Debug("validate", "filename", targetfilename, "filetype", "gitleaks") + case strings.Contains(targetFilename, "gitleaks"): + slog.Debug("validate", "filename", targetFilename, "filetype", "gitleaks") return validateGitleaksReport(reportSrc, config) - case strings.Contains(targetfilename, "syft"): - slog.Debug("validate", "filename", targetfilename, "filetype", "syft") - return errors.New("Syft validation not supported yet.") + case strings.Contains(targetFilename, "syft"): + slog.Debug("validate", "filename", targetFilename, "filetype", "syft") + return errors.New("syft validation not supported yet") - case strings.Contains(targetfilename, "bundle"): - slog.Debug("validate", "filename", targetfilename, "filetype", "bundle") + case strings.Contains(targetFilename, "bundle"): + slog.Debug("validate", "filename", targetFilename, "filetype", "bundle") return validateBundle(reportSrc, config, options) + case artifacts.IsCoverageReport(targetFilename): + slog.Debug("validate", "filename", targetFilename, "filetype", "coverage") + return validateCoverage(reportSrc, targetFilename, config) + default: - slog.Error("unsupported file type, cannot be determined from filename", "filename", targetfilename) + slog.Error("unsupported file type, cannot be determined from filename", "filename", targetFilename) return errors.New("Failed to validate artifact. See log for details.") } } +func removeIgnoredSeverityCVEs(config *Config, report *artifacts.GrypeReportMin, data *epss.Data) { + hasLimits := map[string]bool{ + "critical": config.Grype.SeverityLimit.Critical.Enabled, + "high": config.Grype.SeverityLimit.High.Enabled, + "medium": config.Grype.SeverityLimit.Medium.Enabled, + "low": config.Grype.SeverityLimit.Low.Enabled, + "unknown": false, + "negligible": false, + } + + for severity, hasLimit := range hasLimits { + if hasLimit { + continue + } + + if config.Grype.EPSSLimit.Enabled { + report.Matches = slices.DeleteFunc(report.Matches, func(match artifacts.GrypeMatch) bool { + epssCVE, ok := data.CVEs[match.Vulnerability.ID] + return strings.ToLower(match.Vulnerability.Severity) == severity && (!ok || epssCVE.EPSSValue() < config.Grype.EPSSLimit.Score) + }) + } else { + report.Matches = slices.DeleteFunc(report.Matches, func(match artifacts.GrypeMatch) bool { + return strings.ToLower(match.Vulnerability.Severity) == severity + }) + } + } +} + func ruleGrypeSeverityLimit(config *Config, report *artifacts.GrypeReportMin) bool { validationPass := true @@ -81,6 +115,9 @@ func ruleGrypeSeverityLimit(config *Config, report *artifacts.GrypeReportMin) bo } if matchCount > int(configuredLimit.Limit) { slog.Error("grype severity limit exceeded", "severity", severity, "report", matchCount, "limit", configuredLimit.Limit) + for _, match := range matches { + slog.Info("vulnerability detected", "id", match.Vulnerability.ID, "severity", match.Vulnerability.Severity) + } validationPass = false continue } @@ -354,7 +391,7 @@ func ruleGrypeEPSSLimit(config *Config, report *artifacts.GrypeReportMin, data * slog.Debug("run epss limit rule", "artifact", "grype", "vulnerabilities", len(report.Matches), - "epss_risk_acceptance_score", config.Grype.EPSSRiskAcceptance.Score, + "epss_limit_score", config.Grype.EPSSLimit.Score, ) for _, match := range report.Matches { epssCVE, ok := data.CVEs[match.Vulnerability.ID] @@ -397,7 +434,7 @@ func ruleCyclonedxEPSSLimit(config *Config, report *artifacts.CyclonedxReportMin slog.Debug("run epss limit rule", "artifact", "cyclonedx", "vulnerabilities", len(report.Vulnerabilities), - "epss_risk_acceptance_score", config.Cyclonedx.EPSSRiskAcceptance.Score, + "epss_limit_score", config.Cyclonedx.EPSSLimit.Score, ) for _, vulnerability := range report.Vulnerabilities { @@ -426,6 +463,24 @@ func ruleCyclonedxEPSSLimit(config *Config, report *artifacts.CyclonedxReportMin return true } +func removeIgnoredSemgrepIssues(config *Config, report *artifacts.SemgrepReportMin) { + hasLimits := map[string]bool{ + "error": config.Semgrep.SeverityLimit.Error.Enabled, + "warning": config.Semgrep.SeverityLimit.Warning.Enabled, + "info": config.Semgrep.SeverityLimit.Info.Enabled, + } + + for severity, hasLimit := range hasLimits { + if hasLimit { + continue + } + + report.Results = slices.DeleteFunc(report.Results, func(result artifacts.SemgrepResults) bool { + return strings.EqualFold(result.Extra.Severity, severity) + }) + } +} + func ruleSemgrepSeverityLimit(config *Config, report *artifacts.SemgrepReportMin) bool { slog.Debug( "severity limit rule", "artifact", "semgrep", @@ -453,6 +508,9 @@ func ruleSemgrepSeverityLimit(config *Config, report *artifacts.SemgrepReportMin } if matchCount > int(configuredLimit.Limit) { slog.Error("severity limit exceeded", "artifact", "semgrep", "severity", severity, "report", matchCount, "limit", configuredLimit.Limit) + for _, match := range matches { + slog.Info("Potential issue detected", "severity", match.Extra.Severity, "check_id", match.CheckID, "message", match.Extra.Message) + } validationPass = false continue } @@ -478,6 +536,7 @@ func ruleSemgrepImpactRiskAccept(config *Config, report *artifacts.SemgrepReport results := slices.DeleteFunc(report.Results, func(result artifacts.SemgrepResults) bool { riskAccepted := false + // TODO: make the configuration for risk acceptance less dumb (what would you accept high medium impact and not accept low impact) switch { case config.Semgrep.ImpactRiskAcceptance.High && strings.EqualFold(result.Extra.Metadata.Impact, "high"): riskAccepted = true @@ -489,7 +548,7 @@ func ruleSemgrepImpactRiskAccept(config *Config, report *artifacts.SemgrepReport if riskAccepted { slog.Info( - "risk accepted: epss score is below risk acceptance threshold", + "risk accepted: Semgrep issue impact is below acceptance threshold", "check_id", result.CheckID, "severity", result.Extra.Severity, "impact", result.Extra.Metadata.Impact, @@ -626,6 +685,52 @@ func validateGitleaksReport(r io.Reader, config *Config) error { return validateGitleaksRules(config, report) } +func validateCoverage(src io.Reader, targetFilename string, config *Config) error { + coverageFormat, err := artifacts.GetCoverageMode(targetFilename) + if err != nil { + return err + } + + parser := coverage.New(coverageFormat) + report, err := parser.ParseReader(src) + if err != nil { + return err + } + + lineCoverage := float32(report.CoveredLines) / float32(report.TotalLines) + functionCoverage := float32(report.CoveredFunctions) / float32(report.TotalFunctions) + branchCoverage := float32(report.CoveredBranches) / float32(report.TotalBranches) + + slog.Info( + "validate coverage", + "line_coverage", lineCoverage, + "function_coverage", functionCoverage, + "branch_coverage", branchCoverage, + ) + + var errs error + + if lineCoverage < config.Coverage.LineThreshold { + slog.Error("line coverage below threshold", "line_coverage", lineCoverage, "threshold", config.Coverage.LineThreshold) + coverageErr := newValidationErr("Coverage: Line coverage below threshold") + errs = errors.Join(errs, coverageErr) + } + + if functionCoverage < config.Coverage.FunctionThreshold { + slog.Error("function coverage below threshold", "function_coverage", functionCoverage, "threshold", config.Coverage.FunctionThreshold) + coverageErr := newValidationErr("Coverage: Function coverage below threshold") + errs = errors.Join(errs, coverageErr) + } + + if branchCoverage < config.Coverage.BranchThreshold { + slog.Error("branch coverage below threshold", "branch_coverage", branchCoverage, "threshold", config.Coverage.BranchThreshold) + coverageErr := newValidationErr("Coverage: Branch coverage below threshold") + errs = errors.Join(errs, coverageErr) + } + + return errs +} + func validateBundle(r io.Reader, config *Config, options *fetchOptions) error { slog.Debug("validate gatecheck bundle") bundle := archive.NewBundle() @@ -658,6 +763,9 @@ func validateBundle(r io.Reader, config *Config, options *fetchOptions) error { case strings.Contains(fileLabel, "gitleaks"): err := validateGitleaksReport(bytes.NewBuffer(bundle.FileBytes(fileLabel)), config) errs = errors.Join(errs, err) + case artifacts.IsCoverageReport(fileLabel): + err := validateCoverage(bytes.NewBuffer(bundle.FileBytes(fileLabel)), fileLabel, config) + errs = errors.Join(errs, err) } } if errs != nil { @@ -669,11 +777,34 @@ func validateBundle(r io.Reader, config *Config, options *fetchOptions) error { // Validate Rules func validateGrypeRules(config *Config, report *artifacts.GrypeReportMin, catalog *kev.Catalog, data *epss.Data) error { + severityRank := []string{ + "critical", + "high", + "medium", + "low", + "negligible", + "unknown", + } + sort.Slice(report.Matches, func(i, j int) bool { + if report.Matches[i].Vulnerability.Severity == report.Matches[j].Vulnerability.Severity { + epssi, oki := data.CVEs[report.Matches[i].Vulnerability.ID] + epssj, okj := data.CVEs[report.Matches[j].Vulnerability.ID] + + // Sort EPPS from highest to lowest + return !okj || oki && epssi.EPSSValue() > epssj.EPSSValue() + } + ranki := slices.Index(severityRank, strings.ToLower(report.Matches[i].Vulnerability.Severity)) + rankj := slices.Index(severityRank, strings.ToLower(report.Matches[j].Vulnerability.Severity)) + return ranki < rankj + }) // 1. Deny List - Fail Matching if !ruleGrypeCVEDeny(config, report) { return newValidationErr("Grype: CVE explicitly denied") } + // Ignore any CVEs that don't meet the vulnerability threshold or the EPPS threshold + removeIgnoredSeverityCVEs(config, report, data) + // 2. CVE Allowance - remove from matches ruleGrypeCVEAllow(config, report) @@ -729,6 +860,10 @@ func validateCyclonedxRules(config *Config, report *artifacts.CyclonedxReportMin } func validateSemgrepRules(config *Config, report *artifacts.SemgrepReportMin) error { + slog.Info("validating semgrep rules", "findings", len(report.Results)) + // Ignore issues for which there is no severity limit + removeIgnoredSemgrepIssues(config, report) + // 1. Impact Allowance - remove result ruleSemgrepImpactRiskAccept(config, report)