Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
27 changes: 27 additions & 0 deletions pkg/artifacts/cyclonedx.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"`
}
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions pkg/gatecheck/coverage_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
48 changes: 48 additions & 0 deletions pkg/gatecheck/cyclonedx_link_test.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
30 changes: 16 additions & 14 deletions pkg/gatecheck/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down
57 changes: 32 additions & 25 deletions pkg/gatecheck/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading