diff --git a/CHANGELOG.md b/CHANGELOG.md index aadff86..7fa3c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - A CycloneDX file with `grype` in the name was read as Grype, so its vulnerabilities were ignored. Fixes #80. +- CycloneDX `list` showed the previous row's link for a vulnerability with no advisories. Each row now uses its own advisory URL, then `source.url`, then the first `references[].source.url`, and `-` when there is none. Fixes #81. +- Coverage `list` printed `NaN%` when a report had no records of a kind, such as LCOV without branch records. It now prints `N/A`. `validate` skips that threshold and logs a warning instead of passing it silently. Fixes #82. ## [0.9.0] - 2026-10-23 diff --git a/docs/agent-guide.md b/docs/agent-guide.md index e18b44d..56fc94b 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -129,7 +129,7 @@ Field names below are the ones `config init` emits. In YAML they are case sensit - `severityLimit` has `critical`, `high`, `medium`, and `low`. Each has `enabled` and `limit`, with the same count rule as Grype. The severity comes from the SARIF property `security-severity` when a result or its rule has one. The value is a number from 0 to 10, or a numeric string. 9.0 and above is critical, 7.0 to 8.9 is high, 4.0 to 6.9 is medium, above 0 and below 4.0 is low, and 0 is none. A result property wins over the same property on the rule. When neither is set, gatecheck uses the SARIF level. A missing result level uses the rule's `defaultConfiguration.level`, then `warning`. `error` is high, `warning` is medium, `note` is low, and `none` is ignored. - `ruleLimit` and `ruleRiskAcceptance` each have `enabled` and `rules`. A rule entry is `id` plus an optional `metadata.tags` list. `id` is compared without regard to case. `ruleLimit` denies those ids. `ruleRiskAcceptance` removes them. -`coverage.lineThreshold`, `functionThreshold`, and `branchThreshold` are ratios from 0 to 1: covered lines, functions, or branches divided by the total. The report fails when its ratio is less than the threshold. A ratio equal to the threshold passes. `0.8` is 80 percent. `80` fails a report whose real line coverage is 0.8, because 0.8 is less than 80. There is no `enabled` flag. The default `0` allows any coverage, because a ratio is never less than 0. `list` prints these numbers as percents in the table footer. The config still wants the ratio. +`coverage.lineThreshold`, `functionThreshold`, and `branchThreshold` are ratios from 0 to 1: covered lines, functions, or branches divided by the total. The report fails when its ratio is less than the threshold. A ratio equal to the threshold passes. `0.8` is 80 percent. `80` fails a report whose real line coverage is 0.8, because 0.8 is less than 80. There is no `enabled` flag. The default `0` allows any coverage, because a ratio is never less than 0. `list` prints these numbers as percents in the table footer. The config still wants the ratio. A report with no records of a kind, such as LCOV without branch records, has no ratio for it. `list` prints `N/A`, and `validate` skips that threshold with a warning. Grype, CycloneDX, Trivy, and osv-scanner rules run in this order. A failure returns immediately, except where a rule only removes findings. Trivy counts vulnerability entries only. osv-scanner counts one finding per group. diff --git a/docs/validation.md b/docs/validation.md index bbd29b1..779daac 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -66,7 +66,7 @@ When `limitEnabled` is true, any finding in the report fails validation. There i ## Coverage -Line, function, and branch coverage are each `covered / total`. The config thresholds are those ratios, from 0 to 1. The check fails when the report's ratio is less than the threshold. A ratio equal to the threshold passes. All three checks run, and each failure is reported. +Line, function, and branch coverage are each `covered / total`. The config thresholds are those ratios, from 0 to 1. The check fails when the report's ratio is less than the threshold. A ratio equal to the threshold passes. All three checks run, and each failure is reported. A report with no records of a kind, such as LCOV without branch records, has no ratio for it. `list` prints `N/A`, and `validate` skips that threshold with a warning. `0.8` means 80 percent. `80.0` fails a normal report, because `0.8 < 80`. diff --git a/pkg/artifacts/cyclonedx.go b/pkg/artifacts/cyclonedx.go index 82c83ff..60f1210 100644 --- a/pkg/artifacts/cyclonedx.go +++ b/pkg/artifacts/cyclonedx.go @@ -29,6 +29,8 @@ type CyclonedxVulnerability struct { Advisories []CyclonedxAdvisory `json:"advisories"` Affects []CyclondexAffectedPackage `json:"affects"` Ratings []CyclonedxRating `json:"ratings"` + Source CyclonedxSource `json:"source"` + References []CyclonedxReference `json:"references"` } // Identity is the vulnerability id, or the bom-ref when the producer left id empty. @@ -39,6 +41,30 @@ func (v CyclonedxVulnerability) Identity() string { return v.BOMRef } +// Link is the first advisory URL, then the source URL, then the first reference source URL. +// It returns "-" when the vulnerability has none. +func (v CyclonedxVulnerability) Link() string { + for _, advisory := range v.Advisories { + if strings.TrimSpace(advisory.URL) != "" { + return advisory.URL + } + } + if strings.TrimSpace(v.Source.URL) != "" { + return v.Source.URL + } + for _, ref := range v.References { + if strings.TrimSpace(ref.Source.URL) != "" { + return ref.Source.URL + } + } + return "-" +} + +type CyclonedxReference struct { + ID string `json:"id"` + Source CyclonedxSource `json:"source"` +} + type CyclondexAffectedPackage struct { Ref string `json:"ref"` } @@ -90,6 +116,7 @@ func (s *cyclonedxScore) UnmarshalJSON(data []byte) error { type CyclonedxSource struct { Name string `json:"name"` + URL string `json:"url"` } func (r *CyclonedxReportMin) SelectBySeverity(severity string) []CyclonedxVulnerability { diff --git a/pkg/gatecheck/coverage_test.go b/pkg/gatecheck/coverage_test.go new file mode 100644 index 0000000..a65b5a8 --- /dev/null +++ b/pkg/gatecheck/coverage_test.go @@ -0,0 +1,49 @@ +package gatecheck + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +// lcov --capture without --branch-coverage writes no BRF/BRH records. +const lcovNoBranches = `TN: +SF:main.go +FN:1,main +FNDA:1,main +FNF:1 +FNH:1 +DA:1,1 +DA:2,0 +LF:2 +LH:1 +end_of_record +` + +func TestLCOVWithoutBranchRecords(t *testing.T) { + got := renderList(t, "coverage.info", []byte(lcovNoBranches), nil) + if strings.Contains(got, "NaN") { + t.Fatalf("rendered NaN:\n%s", got) + } + for _, needle := range []string{"50.00%", "100.00%", "N/A"} { + if !strings.Contains(got, needle) { + t.Fatalf("missing %q\n%s", needle, got) + } + } + + config := NewDefaultConfig() + config.Coverage.BranchThreshold = 0.8 + if err := Validate(config, bytes.NewReader([]byte(lcovNoBranches)), "coverage.info"); err != nil { + t.Fatalf("branch threshold applied with no branch records: %v", err) + } + + config.Coverage.LineThreshold = 0.8 + err := Validate(config, bytes.NewReader([]byte(lcovNoBranches)), "coverage.info") + if !errors.Is(err, ErrValidationFailure) || !strings.Contains(err.Error(), "Line coverage below threshold") { + t.Fatalf("got %v", err) + } + if strings.Contains(err.Error(), "Branch") { + t.Fatalf("branch threshold failed with no branch records: %v", err) + } +} diff --git a/pkg/gatecheck/cyclonedx_link_test.go b/pkg/gatecheck/cyclonedx_link_test.go new file mode 100644 index 0000000..7887c49 --- /dev/null +++ b/pkg/gatecheck/cyclonedx_link_test.go @@ -0,0 +1,48 @@ +package gatecheck + +import ( + "strings" + "testing" +) + +func TestCyclonedxLinkIsPerVulnerability(t *testing.T) { + src := []byte(`{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "components": [{"bom-ref": "pkg-a", "name": "a", "version": "1.0.0"}], + "vulnerabilities": [ + {"id": "CVE-2024-0001", "ratings": [{"severity": "critical"}], "advisories": [{"url": "https://example.com/advisory-1"}], "affects": [{"ref": "pkg-a"}]}, + {"id": "CVE-2024-0002", "ratings": [{"severity": "high"}], "source": {"name": "NVD", "url": "https://example.com/source-2"}, "affects": [{"ref": "pkg-a"}]}, + {"id": "CVE-2024-0003", "ratings": [{"severity": "medium"}], "references": [{"id": "GHSA-xxxx", "source": {"url": "https://example.com/ref-3"}}], "affects": [{"ref": "pkg-a"}]}, + {"id": "CVE-2024-0004", "ratings": [{"severity": "low"}], "affects": [{"ref": "pkg-a"}]} + ] +}`) + got := renderList(t, "bom.json", src, nil) + want := map[string]string{ + "CVE-2024-0001": "https://example.com/advisory-1", + "CVE-2024-0002": "https://example.com/source-2", + "CVE-2024-0003": "https://example.com/ref-3", + "CVE-2024-0004": "-", + } + for id, link := range want { + line := lineWith(got, id) + if line == "" { + t.Fatalf("missing %s\n%s", id, got) + } + if !strings.Contains(line, link) { + t.Fatalf("%s: want link %q, got line %q", id, link, line) + } + if link == "-" && strings.Contains(line, "https://") { + t.Fatalf("%s: borrowed another row's link: %q", id, line) + } + } +} + +func lineWith(text, needle string) string { + for _, line := range strings.Split(text, "\n") { + if strings.Contains(line, needle) { + return line + } + } + return "" +} diff --git a/pkg/gatecheck/list.go b/pkg/gatecheck/list.go index 6a1d7c0..a2ed671 100644 --- a/pkg/gatecheck/list.go +++ b/pkg/gatecheck/list.go @@ -176,14 +176,24 @@ func listCoverage(table *tablewriter.Table, kind artifacts.ReportKind, inputFile return err } - 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.Footer([]string{lineCoverageStr, funcCoverageStr, branchCoverageStr}) + table.Footer([]string{ + coveragePercent(report.CoveredLines, report.TotalLines), + coveragePercent(report.CoveredFunctions, report.TotalFunctions), + coveragePercent(report.CoveredBranches, report.TotalBranches), + }) return nil } +// coveragePercent prints "n/a" when the report has no records of that kind. +func coveragePercent(covered, total int) string { + ratio, ok := coverageRatio(covered, total) + if !ok { + return "n/a" + } + return fmt.Sprintf("%0.2f%%", ratio*100) +} + func ListGrypeReport(table *tablewriter.Table, src io.Reader) error { report := &artifacts.GrypeReportMin{} slog.Debug("decode grype report", "format", "json") @@ -279,15 +289,10 @@ func ListCyclonedx(table *tablewriter.Table, src io.Reader) error { catLess := format.NewCatagoricLess([]string{"critical", "high", "medium", "low", "info", "none", "unknown"}) matrix := format.NewSortableMatrix(make([][]string, 0), 1, catLess) - link := "-" for idx, vul := range report.Vulnerabilities { severity := vul.HighestSeverity() pkgs := report.AffectedPackages(idx) - if len(vul.Advisories) > 0 { - link = vul.Advisories[0].URL - } - // get the affected vulnerability - matrix.Append([]string{vul.Identity(), severity, pkgs, link}) + matrix.Append([]string{vul.Identity(), severity, pkgs, vul.Link()}) } if len(report.Vulnerabilities) == 0 { @@ -323,10 +328,7 @@ func listCyclonedxWithEPSS(table *tablewriter.Table, src io.Reader, epssData *ep score = cve.EPSS prctl = cve.Percentile } - link := "-" - if len(item.Advisories) > 0 { - link = item.Advisories[0].URL - } + link := item.Link() row := []string{ item.Identity(), item.HighestSeverity(), diff --git a/pkg/gatecheck/validate.go b/pkg/gatecheck/validate.go index d944fb1..487655a 100644 --- a/pkg/gatecheck/validate.go +++ b/pkg/gatecheck/validate.go @@ -710,38 +710,45 @@ func validateCoverage(src io.Reader, kind artifacts.ReportKind, targetFilename s 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, - ) + checks := []struct { + name string + covered int + total int + threshold float32 + }{ + {"line", report.CoveredLines, report.TotalLines, config.Coverage.LineThreshold}, + {"function", report.CoveredFunctions, report.TotalFunctions, config.Coverage.FunctionThreshold}, + {"branch", report.CoveredBranches, report.TotalBranches, config.Coverage.BranchThreshold}, + } 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) + for _, check := range checks { + ratio, ok := coverageRatio(check.covered, check.total) + if !ok { + if check.threshold > 0 { + slog.Warn("no "+check.name+" records in coverage report, threshold not checked", "threshold", check.threshold) + } + continue + } + slog.Info("validate coverage", check.name+"_coverage", ratio, "threshold", check.threshold) + if ratio < check.threshold { + slog.Error(check.name+" coverage below threshold", check.name+"_coverage", ratio, "threshold", check.threshold) + label := strings.ToUpper(check.name[:1]) + check.name[1:] + errs = errors.Join(errs, newValidationErr("Coverage: "+label+" coverage below threshold")) + } } - 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) - } + return errs +} - 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) +// coverageRatio returns false when total is zero, so a report with no records +// of a kind is not applicable rather than NaN. +func coverageRatio(covered, total int) (float32, bool) { + if total <= 0 { + return 0, false } - - return errs + return float32(covered) / float32(total), true } func validateBundle(r io.Reader, config *Config, options *fetchOptions) error {