checker: suppress CVE warnings for vendor-backported OpenSSH fixes
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing

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:
nemunaire 2026-06-18 16:48:37 +09:00
commit fb2ae7d903
3 changed files with 261 additions and 19 deletions

View file

@ -113,7 +113,7 @@ func (r *knownVulnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
} }
var issues []Issue var issues []Issue
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
issues = append(issues, analyseBannerVulns(ep.Addr(), ep.Banner, ep.SoftVer)...) issues = append(issues, analyseBannerVulns(ep.Addr(), ep.Banner, ep.SoftVer, ep.Vendor)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")} return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")}

View file

@ -47,6 +47,12 @@ type opensshVuln struct {
Description string Description string
Fix string Fix string
AffectedRanges []opensshRange 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 { type opensshRange struct {
@ -54,6 +60,19 @@ type opensshRange struct {
MaxExclusive string // "" means open-ended above 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{ var opensshVulns = []opensshVuln{
{ {
Code: "cve_2024_6387_regreSSHion", Code: "cve_2024_6387_regreSSHion",
@ -68,6 +87,16 @@ var opensshVulns = []opensshVuln{
// The race also existed in < 4.4p1 (CVE-2006-5051 variant). // The race also existed in < 4.4p1 (CVE-2006-5051 variant).
{MaxExclusive: "4.4p1"}, {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", 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 // analyseBannerVulns runs the banner through the OpenSSH CVE database
// and returns the matched issues. The banner parser is deliberately // and returns the matched issues. The upstream version match is
// loose: a server running a vendor-patched OpenSSH (e.g. // deliberately loose, because distribution maintainers tend to backport
// "OpenSSH_9.2p1 Debian-2+deb12u2") will still match the upstream // fixes without changing the version string (e.g. "OpenSSH_9.2p1
// version numbers, because distribution maintainers tend to backport // Debian-2+deb12u3" reports the same 9.2p1 as a vulnerable build). To
// fixes without changing the version string. Operators get to // avoid false positives, a matched CVE is suppressed when the vendor
// override these false positives at the UI layer, same as other // comment identifies a distribution package known to carry the
// checkers. // backported fix (see vendorFix). Any residual false positives can
func analyseBannerVulns(addr, banner, software string) []Issue { // still be overridden at the UI layer, same as other checkers.
func analyseBannerVulns(addr, banner, software, vendor string) []Issue {
if banner == "" { if banner == "" {
return nil return nil
} }
@ -164,7 +194,12 @@ func analyseBannerVulns(addr, banner, software string) []Issue {
} }
var issues []Issue var issues []Issue
for _, v := range opensshVulns { for _, v := range opensshVulns {
if rangesMatch(ver, v.AffectedRanges) { if !rangesMatch(ver, v.AffectedRanges) {
continue
}
if vendorPatched(software, vendor, v.VendorFixes) {
continue
}
issues = append(issues, Issue{ issues = append(issues, Issue{
Code: v.Code, Code: v.Code,
Severity: v.Severity, Severity: v.Severity,
@ -173,16 +208,57 @@ func analyseBannerVulns(addr, banner, software string) []Issue {
Endpoint: addr, Endpoint: addr,
}) })
} }
}
return issues 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. // analyseBanner combines software-awareness and vulnerability matches.
// Retained as a convenience for the HTML report, which surfaces both // Retained as a convenience for the HTML report, which surfaces both
// concerns in a single "What to fix" list. // 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 := analyseBannerSoftware(addr, banner, software)
out = append(out, analyseBannerVulns(addr, banner, software)...) out = append(out, analyseBannerVulns(addr, banner, software, vendor)...)
return out return out
} }
@ -247,3 +323,77 @@ func parseVersionString(s string) (opensshVersion, bool) {
} }
return *v, true 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' }

View file

@ -0,0 +1,92 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import "testing"
func hasIssue(issues []Issue, code string) bool {
for _, i := range issues {
if i.Code == code {
return true
}
}
return false
}
func TestVendorFixSuppressesRegreSSHion(t *testing.T) {
const code = "cve_2024_6387_regreSSHion"
cases := []struct {
name string
soft string
vendor string
flagged bool
}{
// Vanilla upstream in the affected window: must flag.
{"upstream 9.2p1", "OpenSSH_9.2p1", "", true},
// Debian bookworm before / at / after the fix.
{"debian unpatched", "OpenSSH_9.2p1", "Debian-2+deb12u2", true},
{"debian patched", "OpenSSH_9.2p1", "Debian-2+deb12u3", false},
{"debian later point release", "OpenSSH_9.2p1", "Debian-2+deb12u10", false},
// Ubuntu jammy: numeric ".10" must beat ".2" (dpkg numeric run).
{"ubuntu jammy unpatched", "OpenSSH_8.9p1", "Ubuntu-3ubuntu0.2", true},
{"ubuntu jammy patched", "OpenSSH_8.9p1", "Ubuntu-3ubuntu0.10", false},
// Ubuntu noble.
{"ubuntu noble patched", "OpenSSH_9.6p1", "Ubuntu-3ubuntu13.3", false},
{"ubuntu noble unpatched", "OpenSSH_9.6p1", "Ubuntu-3ubuntu13.2", true},
// A fix recorded for a different upstream version must not apply.
{"vendor mismatch upstream", "OpenSSH_9.3p1", "Debian-2+deb12u3", true},
// Not affected at all (below 8.5p1): never flagged regardless.
{"debian bullseye", "OpenSSH_8.4p1", "Debian-5+deb11u1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
banner := "SSH-2.0-" + tc.soft
if tc.vendor != "" {
banner += " " + tc.vendor
}
issues := analyseBannerVulns("host:22", banner, tc.soft, tc.vendor)
if got := hasIssue(issues, code); got != tc.flagged {
t.Fatalf("regreSSHion flagged=%v, want %v (issues=%v)", got, tc.flagged, issues)
}
})
}
}
func TestDpkgVerCmp(t *testing.T) {
cases := []struct {
a, b string
want int // sign
}{
{"2+deb12u3", "2+deb12u2", 1},
{"2+deb12u3", "2+deb12u3", 0},
{"2+deb12u3", "2+deb12u10", -1},
{"3ubuntu0.10", "3ubuntu0.2", 1},
{"3ubuntu13.3", "3ubuntu13.2", 1},
{"1.0", "1.0~rc1", 1}, // tilde sorts before everything
}
for _, tc := range cases {
got := dpkgVerCmp(tc.a, tc.b)
if (got > 0) != (tc.want > 0) || (got < 0) != (tc.want < 0) {
t.Errorf("dpkgVerCmp(%q,%q)=%d, want sign %d", tc.a, tc.b, got, tc.want)
}
}
}