From 258d799a973ccc25dc7d6af9fb6e87498ce6c757 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 18 Jun 2026 14:14:31 +0900 Subject: [PATCH 1/2] checker: implement ShareKey to mutualise SSH probes across targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An SSH probe (reachability, banner, KEX/host-key algorithm posture, host keys) depends only on the set of addresses and ports dialed and the probe knobs, never on which domain name points at the server: SSH has no SNI, so the same daemon answers identically behind every name. Implement sdk.ObservationSharer so the host can probe an address set once and serve every target (of the same user) that points at it, instead of re-connecting per record. The share key sorts the resolved addresses and ports and folds in the probe timeout, the auth-probe flag, and the declared SSHFP fingerprints — the latter live in the observation and drive the SSHFP-match rule, so two services with the same endpoints but different SSHFP must not share a verdict. The host/Domain label is intentionally excluded, mirroring the ping checker's exclusion of which domain the addresses belong to: it does not change reachability, the negotiated algorithms, the host keys, or the SSHFP comparison. Inputs with no probable address yield "" so the host falls back to per-target caching. --- checker/collect.go | 73 +++++++++++++++++++++++++ checker/sharekey_test.go | 115 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 checker/sharekey_test.go diff --git a/checker/collect.go b/checker/collect.go index d7d3d08..87deb16 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -23,10 +23,13 @@ package checker import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" "net" + "sort" "strconv" "strings" "sync" @@ -116,6 +119,76 @@ func (p *sshProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any return data, nil } +// ShareKey implements sdk.ObservationSharer. An SSH probe (reachability, banner, +// KEX/host-key algorithm posture, host keys) depends only on the set of +// addresses and ports dialed and the probe knobs, never on which domain name +// points at the server: SSH has no SNI, so the same daemon answers identically +// behind every name. The declared SSHFP fingerprints are folded in because they +// live in the observation and drive the SSHFP-match rule — two services with the +// same endpoints but different SSHFP must not share a verdict. +// +// The host/Domain label is intentionally excluded, mirroring the ping checker's +// exclusion of "which domain the addresses belong to": it is a display label +// that does not change reachability, the negotiated algorithms, the host keys, +// or the SSHFP comparison. Inputs with no probable address return "" so the host +// falls back to the default per-target caching. +func (p *sshProvider) ShareKey(opts sdk.CheckerOptions) (string, error) { + server, err := resolveServer(opts) + if err != nil { + return "", nil + } + + apex := "" + if v, ok := sdk.GetOption[string](opts, OptionDomainName); ok { + apex = strings.TrimSuffix(v, ".") + } + subdomain := "" + if svc, ok := sdk.GetOption[happydns.ServiceMessage](opts, OptionService); ok { + subdomain = strings.TrimSuffix(svc.Domain, ".") + } + origin := sdk.JoinRelative(subdomain, apex) + _, ips := addressesFromServer(server, origin) + if len(ips) == 0 { + return "", nil + } + + ports := parsePorts(optString(opts, OptionPorts, "")) + if len(ports) == 0 { + ports = []uint16{DefaultSSHPort} + } + + timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs) + if timeoutMs <= 0 { + timeoutMs = DefaultProbeTimeoutMs + } + includeAuthProbe := sdk.GetBoolOption(opts, OptionIncludeAuthProbe, true) + + sortedIPs := append([]string(nil), ips...) + sort.Strings(sortedIPs) + + sortedPorts := append([]uint16(nil), ports...) + sort.Slice(sortedPorts, func(i, j int) bool { return sortedPorts[i] < sortedPorts[j] }) + portStrs := make([]string, len(sortedPorts)) + for i, p := range sortedPorts { + portStrs[i] = strconv.Itoa(int(p)) + } + + sshfp := sshfpFromServer(server) + fps := make([]string, 0, len(sshfp.Records)) + for _, r := range sshfp.Records { + fps = append(fps, fmt.Sprintf("%d:%d:%s", r.Algorithm, r.Type, r.Fingerprint)) + } + sort.Strings(fps) + + h := sha256.Sum256(fmt.Appendf(nil, "%d|%t|%s|%s|%s", + timeoutMs, includeAuthProbe, + strings.Join(sortedIPs, ","), + strings.Join(portStrs, ","), + strings.Join(fps, ","), + )) + return "ssh:" + hex.EncodeToString(h[:8]), nil +} + // resolveServer extracts the *abstract.Server payload from the options. // Two shapes are supported, same as the ping checker: // - "service": ServiceMessage (in-process plugin path, or HTTP after diff --git a/checker/sharekey_test.go b/checker/sharekey_test.go new file mode 100644 index 0000000..968f0ab --- /dev/null +++ b/checker/sharekey_test.go @@ -0,0 +1,115 @@ +package checker + +import ( + "encoding/json" + "net" + "strings" + "testing" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" + happydns "git.happydns.org/happyDomain/model" + "git.happydns.org/happyDomain/services/abstract" +) + +func serverOpts(t *testing.T, domain string, srv abstract.Server, extra map[string]any) sdk.CheckerOptions { + t.Helper() + raw, err := json.Marshal(srv) + if err != nil { + t.Fatal(err) + } + opts := sdk.CheckerOptions{ + OptionService: happydns.ServiceMessage{ + ServiceMeta: happydns.ServiceMeta{ + Type: "abstract.Server", + Domain: domain, + }, + Service: raw, + }, + OptionDomainName: "example.org", + } + for k, v := range extra { + opts[k] = v + } + return opts +} + +func TestShareKey_StableAndPrefixed(t *testing.T) { + p := &sshProvider{} + srv := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + + a, err := p.ShareKey(serverOpts(t, "host", srv, nil)) + if err != nil { + t.Fatal(err) + } + b, _ := p.ShareKey(serverOpts(t, "host", srv, nil)) + if a != b { + t.Fatalf("non-deterministic key: %q vs %q", a, b) + } + if !strings.HasPrefix(a, "ssh:") { + t.Fatalf("missing prefix: %q", a) + } +} + +// The host/Domain label must not change the key: the same server answers +// identically behind every name (SSH has no SNI). +func TestShareKey_IgnoresDomainLabel(t *testing.T) { + p := &sshProvider{} + srv := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + + a, _ := p.ShareKey(serverOpts(t, "alpha", srv, nil)) + b, _ := p.ShareKey(serverOpts(t, "beta", srv, nil)) + if a != b { + t.Fatalf("domain label must not affect the key: %q vs %q", a, b) + } +} + +func TestShareKey_DiffersByAddress(t *testing.T) { + p := &sshProvider{} + a, _ := p.ShareKey(serverOpts(t, "host", abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}}, nil)) + b, _ := p.ShareKey(serverOpts(t, "host", abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.2").To4()}}, nil)) + if a == b { + t.Fatalf("different addresses must yield different keys") + } +} + +func TestShareKey_DiffersByPortsAndKnobs(t *testing.T) { + p := &sshProvider{} + srv := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + + def, _ := p.ShareKey(serverOpts(t, "host", srv, nil)) + ports, _ := p.ShareKey(serverOpts(t, "host", srv, map[string]any{OptionPorts: "22,2222"})) + auth, _ := p.ShareKey(serverOpts(t, "host", srv, map[string]any{OptionIncludeAuthProbe: false})) + timeout, _ := p.ShareKey(serverOpts(t, "host", srv, map[string]any{OptionProbeTimeoutMs: float64(1000)})) + + for name, got := range map[string]string{"ports": ports, "authProbe": auth, "timeout": timeout} { + if got == def { + t.Fatalf("%s must affect the key", name) + } + } +} + +// SSHFP fingerprints live in the observation and drive the SSHFP-match rule, so +// they must be part of the key. +func TestShareKey_DiffersBySSHFP(t *testing.T) { + p := &sshProvider{} + bare := abstract.Server{A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}} + withFP := abstract.Server{ + A: &dns.A{A: net.ParseIP("192.0.2.1").To4()}, + SSHFP: []*dns.SSHFP{{Algorithm: 4, Type: 2, FingerPrint: "abcdef"}}, + } + + a, _ := p.ShareKey(serverOpts(t, "host", bare, nil)) + b, _ := p.ShareKey(serverOpts(t, "host", withFP, nil)) + if a == b { + t.Fatalf("declared SSHFP must affect the key") + } +} + +func TestShareKey_EmptyWhenNoAddress(t *testing.T) { + p := &sshProvider{} + if sk, err := p.ShareKey(sdk.CheckerOptions{}); err != nil || sk != "" { + t.Fatalf("expected empty key, got %q (err=%v)", sk, err) + } +} From fb2ae7d9037ae4934714ec952c330935e40a1ffa Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 18 Jun 2026 16:48:37 +0900 Subject: [PATCH 2/2] 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). --- checker/rules_banner.go | 2 +- checker/vulns.go | 186 ++++++++++++++++++++++++++++---- checker/vulns_vendorfix_test.go | 92 ++++++++++++++++ 3 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 checker/vulns_vendorfix_test.go 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) + } + } +}