feat: version comparators for distro package ordering

This commit is contained in:
2026-08-06 11:54:13 +01:00
parent 3afc4ab012
commit bd24b03cac
3 changed files with 84 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
// Package vulndb matches installed packages against distribution security
// advisories.
//
// Version comparison is bought rather than written. Distribution version
// ordering is subtle in ways that are invisible until they are wrong: dpkg has
// epochs and sorts "~" before the empty string, rpmvercmp has its own segment
// rules and treats "~" and "^" differently again, and any ordering that falls
// back on string comparison puts 1.10 before 1.9. Every one of those mistakes
// produces a false negative — a vulnerable host reported clean — which is the
// failure nobody notices.
package vulndb
import (
"errors"
"fmt"
apk "github.com/knqyf263/go-apk-version"
deb "github.com/knqyf263/go-deb-version"
rpm "github.com/knqyf263/go-rpm-version"
)
// ErrUnsupportedFamily means we hold no comparator for this distribution, and
// therefore cannot answer whether it is vulnerable. Callers must surface this
// as "unsupported" and must never treat it as "not vulnerable".
var ErrUnsupportedFamily = errors.New("unsupported OS family")
// LessThan reports whether version a sorts before version b under the ordering
// rules of the given OS family.
//
// An unparseable or empty version is an error, never a quiet false. False here
// means "not vulnerable", which is the dangerous direction to guess in.
func LessThan(family, a, b string) (bool, error) {
switch family {
case "debian", "ubuntu":
if a == "" || b == "" {
return false, fmt.Errorf("empty deb version (a=%q b=%q)", a, b)
}
va, err := deb.NewVersion(a)
if err != nil {
return false, fmt.Errorf("parse deb version %q: %w", a, err)
}
vb, err := deb.NewVersion(b)
if err != nil {
return false, fmt.Errorf("parse deb version %q: %w", b, err)
}
return va.LessThan(vb), nil
case "redhat", "centos", "rocky", "alma", "amazon", "oracle", "suse", "opensuse", "sles":
// go-rpm-version does not error; rpmvercmp is defined over arbitrary
// strings. Guard empties so a missing version cannot read as equal.
if a == "" || b == "" {
return false, fmt.Errorf("empty rpm version (a=%q b=%q)", a, b)
}
return rpm.NewVersion(a).LessThan(rpm.NewVersion(b)), nil
case "alpine":
if a == "" || b == "" {
return false, fmt.Errorf("empty apk version (a=%q b=%q)", a, b)
}
va, err := apk.NewVersion(a)
if err != nil {
return false, fmt.Errorf("parse apk version %q: %w", a, err)
}
vb, err := apk.NewVersion(b)
if err != nil {
return false, fmt.Errorf("parse apk version %q: %w", b, err)
}
return va.LessThan(vb), nil
default:
return false, fmt.Errorf("%w: %s", ErrUnsupportedFamily, family)
}
}