From c277ecff44c004614b8bd7c1ee91ad5f51d396c2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 6 Aug 2026 11:56:41 +0100 Subject: [PATCH] feat: agent collects installed packages per package manager --- agent/internal/packages/packages.go | 73 ++++++++++++++ agent/internal/packages/parse.go | 146 ++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 agent/internal/packages/packages.go create mode 100644 agent/internal/packages/parse.go diff --git a/agent/internal/packages/packages.go b/agent/internal/packages/packages.go new file mode 100644 index 0000000..fafcacc --- /dev/null +++ b/agent/internal/packages/packages.go @@ -0,0 +1,73 @@ +package packages + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "time" +) + +const collectTimeout = 2 * time.Minute + +// Collect enumerates installed packages. Linux only: Windows agents are +// second-class by design, and vulnerability scanning there needs a different +// source, a different collector and a different matcher, all out of scope. +// +// The format strings below are raw string literals on purpose. The "\t" and +// "\n" reach dpkg-query and rpm as two characters each, and those tools do the +// interpreting themselves — Go must not consume the escapes first. +func Collect() (OSRelease, []Package, error) { + if runtime.GOOS != "linux" { + return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS) + } + + osrel, err := DetectOS() + if err != nil { + return OSRelease{}, nil, fmt.Errorf("detect os: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), collectTimeout) + defer cancel() + + switch { + case have("dpkg-query"): + out, err := run(ctx, "dpkg-query", "-W", "-f", + `${Package}\t${Version}\t${Architecture}\t${source:Package}\n`) + if err != nil { + return osrel, nil, err + } + return osrel, ParseDpkg(out), nil + + case have("rpm"): + out, err := run(ctx, "rpm", "-qa", "--qf", + `%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`) + if err != nil { + return osrel, nil, err + } + return osrel, ParseRPM(out), nil + + case have("apk"): + out, err := run(ctx, "apk", "info", "-v") + if err != nil { + return osrel, nil, err + } + return osrel, ParseAPK(out), nil + + default: + return osrel, nil, fmt.Errorf("no supported package manager found") + } +} + +func have(bin string) bool { + _, err := exec.LookPath(bin) + return err == nil +} + +func run(ctx context.Context, name string, args ...string) (string, error) { + out, err := exec.CommandContext(ctx, name, args...).Output() + if err != nil { + return "", fmt.Errorf("%s: %w", name, err) + } + return string(out), nil +} diff --git a/agent/internal/packages/parse.go b/agent/internal/packages/parse.go new file mode 100644 index 0000000..41adf04 --- /dev/null +++ b/agent/internal/packages/parse.go @@ -0,0 +1,146 @@ +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}\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. +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 + } + 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)) +}