diff --git a/checker/rules_banner.go b/checker/rules_banner.go index 6758838..6ee1c19 100644 --- a/checker/rules_banner.go +++ b/checker/rules_banner.go @@ -113,7 +113,7 @@ func (r *knownVulnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter } var issues []Issue 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 { return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")} diff --git a/checker/vulns.go b/checker/vulns.go index d74415e..5be7740 100644 --- a/checker/vulns.go +++ b/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' } diff --git a/checker/vulns_vendorfix_test.go b/checker/vulns_vendorfix_test.go new file mode 100644 index 0000000..48c51d3 --- /dev/null +++ b/checker/vulns_vendorfix_test.go @@ -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 . +// +// 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 . + +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) + } + } +}