checker: suppress CVE warnings for vendor-backported OpenSSH fixes
Distributions backport security fixes without bumping the upstream OpenSSH version, so a banner like "OpenSSH_9.2p1 Debian-2+deb12u3" was wrongly flagged for regreSSHion despite carrying the fix. Thread the banner vendor comment into analyseBannerVulns and add a per-CVE VendorFixes table recording the earliest patched package revision per distro/upstream version. Revisions are compared with a faithful port of dpkg's verrevcmp ordering. Populated for CVE-2024-6387 from DSA-5724-1 (Debian) and USN-6859-1 (Ubuntu).
This commit is contained in:
parent
258d799a97
commit
fb2ae7d903
3 changed files with 261 additions and 19 deletions
186
checker/vulns.go
186
checker/vulns.go
|
|
@ -47,6 +47,12 @@ type opensshVuln struct {
|
|||
Description string
|
||||
Fix string
|
||||
AffectedRanges []opensshRange
|
||||
// VendorFixes lists distribution packages that backport the fix
|
||||
// without bumping the upstream version string. When the observed
|
||||
// banner matches one of these (same vendor and upstream version,
|
||||
// with a package revision at or above FixedFrom), the CVE is
|
||||
// suppressed even though AffectedRanges would otherwise match.
|
||||
VendorFixes []vendorFix
|
||||
}
|
||||
|
||||
type opensshRange struct {
|
||||
|
|
@ -54,6 +60,19 @@ type opensshRange struct {
|
|||
MaxExclusive string // "" means open-ended above
|
||||
}
|
||||
|
||||
// vendorFix records the earliest distribution package revision that
|
||||
// carries a backported fix for a given upstream OpenSSH version. The
|
||||
// Vendor is the leading token of the banner's vendor comment (e.g.
|
||||
// "Debian" in "Debian-2+deb12u3"); Upstream is the upstream version
|
||||
// the distro ships (e.g. "9.2p1"); FixedFrom is the package revision
|
||||
// that first shipped the fix (e.g. "2+deb12u3"). Revisions are compared
|
||||
// with the dpkg version-ordering algorithm.
|
||||
type vendorFix struct {
|
||||
Vendor string
|
||||
Upstream string
|
||||
FixedFrom string
|
||||
}
|
||||
|
||||
var opensshVulns = []opensshVuln{
|
||||
{
|
||||
Code: "cve_2024_6387_regreSSHion",
|
||||
|
|
@ -68,6 +87,16 @@ var opensshVulns = []opensshVuln{
|
|||
// The race also existed in < 4.4p1 (CVE-2006-5051 variant).
|
||||
{MaxExclusive: "4.4p1"},
|
||||
},
|
||||
// Distributions backported the fix without changing the upstream
|
||||
// version. Sources: DSA-5724-1 (Debian) and USN-6859-1 (Ubuntu).
|
||||
// Debian bullseye (8.4p1) and Ubuntu focal (8.2p1) ship versions
|
||||
// below 8.5p1 and are therefore not affected at all.
|
||||
VendorFixes: []vendorFix{
|
||||
{Vendor: "Debian", Upstream: "9.2p1", FixedFrom: "2+deb12u3"}, // bookworm
|
||||
{Vendor: "Ubuntu", Upstream: "9.6p1", FixedFrom: "3ubuntu13.3"}, // noble 24.04
|
||||
{Vendor: "Ubuntu", Upstream: "9.3p1", FixedFrom: "1ubuntu3.6"}, // mantic 23.10
|
||||
{Vendor: "Ubuntu", Upstream: "8.9p1", FixedFrom: "3ubuntu0.10"}, // jammy 22.04
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "cve_2023_38408_agent",
|
||||
|
|
@ -147,14 +176,15 @@ func analyseBannerSoftware(addr, banner, software string) []Issue {
|
|||
}
|
||||
|
||||
// analyseBannerVulns runs the banner through the OpenSSH CVE database
|
||||
// and returns the matched issues. The banner parser is deliberately
|
||||
// loose: a server running a vendor-patched OpenSSH (e.g.
|
||||
// "OpenSSH_9.2p1 Debian-2+deb12u2") will still match the upstream
|
||||
// version numbers, because distribution maintainers tend to backport
|
||||
// fixes without changing the version string. Operators get to
|
||||
// override these false positives at the UI layer, same as other
|
||||
// checkers.
|
||||
func analyseBannerVulns(addr, banner, software string) []Issue {
|
||||
// and returns the matched issues. The upstream version match is
|
||||
// deliberately loose, because distribution maintainers tend to backport
|
||||
// fixes without changing the version string (e.g. "OpenSSH_9.2p1
|
||||
// Debian-2+deb12u3" reports the same 9.2p1 as a vulnerable build). To
|
||||
// avoid false positives, a matched CVE is suppressed when the vendor
|
||||
// comment identifies a distribution package known to carry the
|
||||
// backported fix (see vendorFix). Any residual false positives can
|
||||
// still be overridden at the UI layer, same as other checkers.
|
||||
func analyseBannerVulns(addr, banner, software, vendor string) []Issue {
|
||||
if banner == "" {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -164,25 +194,71 @@ func analyseBannerVulns(addr, banner, software string) []Issue {
|
|||
}
|
||||
var issues []Issue
|
||||
for _, v := range opensshVulns {
|
||||
if rangesMatch(ver, v.AffectedRanges) {
|
||||
issues = append(issues, Issue{
|
||||
Code: v.Code,
|
||||
Severity: v.Severity,
|
||||
Message: fmt.Sprintf("%s: %s", v.Title, v.Description),
|
||||
Fix: v.Fix,
|
||||
Endpoint: addr,
|
||||
})
|
||||
if !rangesMatch(ver, v.AffectedRanges) {
|
||||
continue
|
||||
}
|
||||
if vendorPatched(software, vendor, v.VendorFixes) {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, Issue{
|
||||
Code: v.Code,
|
||||
Severity: v.Severity,
|
||||
Message: fmt.Sprintf("%s: %s", v.Title, v.Description),
|
||||
Fix: v.Fix,
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// vendorPatched reports whether the banner's vendor comment identifies a
|
||||
// distribution package that has backported the fix for this CVE. The
|
||||
// vendor comment ("Debian-2+deb12u3") is split into a vendor name and a
|
||||
// package revision; a fix applies when the vendor name and the upstream
|
||||
// version both match and the observed revision is at or above the
|
||||
// recorded FixedFrom revision (dpkg ordering).
|
||||
func vendorPatched(software, vendor string, fixes []vendorFix) bool {
|
||||
if vendor == "" || len(fixes) == 0 {
|
||||
return false
|
||||
}
|
||||
name, revision := splitVendorComment(vendor)
|
||||
if name == "" || revision == "" {
|
||||
return false
|
||||
}
|
||||
upstream := upstreamVersionString(software)
|
||||
for _, f := range fixes {
|
||||
if !strings.EqualFold(f.Vendor, name) || f.Upstream != upstream {
|
||||
continue
|
||||
}
|
||||
if dpkgVerCmp(revision, f.FixedFrom) >= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitVendorComment splits a banner vendor comment such as
|
||||
// "Debian-2+deb12u3" or "Ubuntu-3ubuntu0.10" into its vendor name and
|
||||
// package revision on the first '-'.
|
||||
func splitVendorComment(vendor string) (name, revision string) {
|
||||
name, revision, _ = strings.Cut(vendor, "-")
|
||||
return name, revision
|
||||
}
|
||||
|
||||
// upstreamVersionString returns the bare upstream version token from a
|
||||
// software identifier, e.g. "OpenSSH_9.2p1" -> "9.2p1". It returns ""
|
||||
// when the identifier is not a recognised OpenSSH banner.
|
||||
func upstreamVersionString(software string) string {
|
||||
m := opensshBannerRe.FindString(software)
|
||||
return strings.TrimPrefix(m, "OpenSSH_")
|
||||
}
|
||||
|
||||
// analyseBanner combines software-awareness and vulnerability matches.
|
||||
// Retained as a convenience for the HTML report, which surfaces both
|
||||
// concerns in a single "What to fix" list.
|
||||
func analyseBanner(addr, banner, software string) []Issue {
|
||||
func analyseBanner(addr, banner, software, vendor string) []Issue {
|
||||
out := analyseBannerSoftware(addr, banner, software)
|
||||
out = append(out, analyseBannerVulns(addr, banner, software)...)
|
||||
out = append(out, analyseBannerVulns(addr, banner, software, vendor)...)
|
||||
return out
|
||||
}
|
||||
|
||||
|
|
@ -247,3 +323,77 @@ func parseVersionString(s string) (opensshVersion, bool) {
|
|||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// dpkgVerCmp compares two Debian/Ubuntu package version segments using
|
||||
// the dpkg version-ordering algorithm (verrevcmp). It returns a negative
|
||||
// value when a sorts before b, zero when equal, and a positive value
|
||||
// when a sorts after b. We feed it the package-revision portion of a
|
||||
// banner vendor comment (e.g. "2+deb12u3" vs "2+deb12u2"), which is
|
||||
// sufficient because backport comparisons are always within the same
|
||||
// upstream version.
|
||||
func dpkgVerCmp(a, b string) int {
|
||||
i, j := 0, 0
|
||||
for i < len(a) || j < len(b) {
|
||||
// Compare the non-digit prefixes character by character using
|
||||
// dpkg's special ordering (notably '~' sorts before everything,
|
||||
// including the end of string).
|
||||
for (i < len(a) && !isASCIIDigit(a[i])) || (j < len(b) && !isASCIIDigit(b[j])) {
|
||||
ac, bc := 0, 0
|
||||
if i < len(a) {
|
||||
ac = dpkgOrder(a[i])
|
||||
}
|
||||
if j < len(b) {
|
||||
bc = dpkgOrder(b[j])
|
||||
}
|
||||
if ac != bc {
|
||||
return ac - bc
|
||||
}
|
||||
i++
|
||||
j++
|
||||
}
|
||||
// Skip leading zeros so digit runs compare by numeric value.
|
||||
for i < len(a) && a[i] == '0' {
|
||||
i++
|
||||
}
|
||||
for j < len(b) && b[j] == '0' {
|
||||
j++
|
||||
}
|
||||
// Compare the digit runs: a longer run of significant digits is
|
||||
// the larger number; on equal length the first differing digit
|
||||
// decides.
|
||||
firstDiff := 0
|
||||
for i < len(a) && isASCIIDigit(a[i]) && j < len(b) && isASCIIDigit(b[j]) {
|
||||
if firstDiff == 0 {
|
||||
firstDiff = int(a[i]) - int(b[j])
|
||||
}
|
||||
i++
|
||||
j++
|
||||
}
|
||||
if i < len(a) && isASCIIDigit(a[i]) {
|
||||
return 1
|
||||
}
|
||||
if j < len(b) && isASCIIDigit(b[j]) {
|
||||
return -1
|
||||
}
|
||||
if firstDiff != 0 {
|
||||
return firstDiff
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// dpkgOrder maps a non-digit byte to its dpkg sort weight: '~' sorts
|
||||
// before everything (including end-of-string, weight 0), letters keep
|
||||
// their ASCII value, and any other character sorts after letters.
|
||||
func dpkgOrder(c byte) int {
|
||||
switch {
|
||||
case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'):
|
||||
return int(c)
|
||||
case c == '~':
|
||||
return -1
|
||||
default:
|
||||
return int(c) + 256
|
||||
}
|
||||
}
|
||||
|
||||
func isASCIIDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue