Compare commits

...

2 commits

Author SHA1 Message Date
fb2ae7d903 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).
2026-06-18 16:55:19 +09:00
258d799a97 checker: implement ShareKey to mutualise SSH probes across targets
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.
2026-06-18 15:28:28 +09:00
5 changed files with 449 additions and 19 deletions

View file

@ -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

View file

@ -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.")}

115
checker/sharekey_test.go Normal file
View file

@ -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)
}
}

View file

@ -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' }

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)
}
}
}