package packages import ( "crypto/sha256" "encoding/hex" "sort" "strconv" "strings" ) // Package is one installed package as the distribution reports it. Version is // the distribution's own version string, verbatim - never normalised, because // the advisory feeds are keyed on exactly this form. type Package struct { Name string Version string Epoch int Arch string SourceName string } // ParseDpkg reads tab-separated output of // dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n' // // SourceName is why the fourth column is requested at all: Debian and Ubuntu // advisories are keyed on the SOURCE package, so one CVE against "openssl" // covers the binaries libssl3, openssl and libssl-dev. Matching on binary name // alone finds one of the three. // // The fifth column is why "rc" packages do not appear. dpkg-query -W lists // every package dpkg knows about, including ones removed with their config // files left behind - a host that has upgraded its kernel a dozen times reports // a dozen old linux-modules versions that are not on disk, and the oldest of // them sorts first and reads as the installed version. Only "installed" is // installed. An empty status means dpkg did not understand the field, in which // case the line is kept rather than the whole inventory silently vanishing. func ParseDpkg(out string) []Package { var pkgs []Package for _, line := range strings.Split(out, "\n") { if strings.TrimSpace(line) == "" { continue } f := strings.Split(line, "\t") if len(f) < 3 { continue } if len(f) > 4 { if s := strings.TrimSpace(f[4]); s != "" && s != "installed" { continue } } p := Package{Name: f[0], Version: f[1], Arch: f[2]} if len(f) > 3 && f[3] != "" { p.SourceName = f[3] } else { p.SourceName = p.Name } pkgs = append(pkgs, p) } return pkgs } // ParseRPM reads tab-separated output of // rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n' func ParseRPM(out string) []Package { var pkgs []Package for _, line := range strings.Split(out, "\n") { if strings.TrimSpace(line) == "" { continue } f := strings.Split(line, "\t") if len(f) < 4 { continue } epoch := 0 // rpm prints "(none)" rather than omitting the field when a package has // no epoch. That must become 0, not fail the line. if f[1] != "" && f[1] != "(none)" { if n, err := strconv.Atoi(f[1]); err == nil { epoch = n } } p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]} if len(f) > 4 { p.SourceName = srcRPMName(f[4]) } if p.SourceName == "" { p.SourceName = p.Name } pkgs = append(pkgs, p) } return pkgs } // srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping // the trailing ".src.rpm" and then the version and release segments, which are // the last two hyphen-separated fields. func srcRPMName(s string) string { s = strings.TrimSuffix(s, ".src.rpm") parts := strings.Split(s, "-") if len(parts) <= 2 { return s } return strings.Join(parts[:len(parts)-2], "-") } // ParseAPK reads "apk info -v" output: one "name-version-rREV" per line. // Alpine has no separate source package, so SourceName mirrors Name. func ParseAPK(out string) []Package { var pkgs []Package for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } name, version := splitAPK(line) if name == "" { continue } pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name}) } return pkgs } // splitAPK finds the version boundary from the RIGHT. The version is always the // last two hyphen-separated fields ("-r"), which is reliable // where scanning from the left is not: package names legitimately contain // digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be // found by looking for the first digit. func splitAPK(s string) (name, version string) { last := strings.LastIndex(s, "-") if last <= 0 { return "", "" } prev := strings.LastIndex(s[:last], "-") if prev <= 0 { return "", "" } return s[:prev], s[prev+1:] } // Hash fingerprints a package set so an unchanged set never has to be sent. // // It sorts first: the ordering of dpkg or rpm output is not guaranteed stable, // and an ordering-sensitive hash would resend the full ~150KB list every hour // for no reason - a cost visible only as traffic. func Hash(pkgs []Package) string { lines := make([]string, 0, len(pkgs)) for _, p := range pkgs { lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch) } sort.Strings(lines) h := sha256.New() for _, l := range lines { h.Write([]byte(l)) h.Write([]byte("\n")) } return hex.EncodeToString(h.Sum(nil)) }