160 lines
4.7 KiB
Go
160 lines
4.7 KiB
Go
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 ("<version>-r<rev>"), 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))
|
|
}
|