go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/providers/os/resources/packages/rpm_packages.go (about)

     1  // Copyright (c) Mondoo, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package packages
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strconv"
    15  	"strings"
    16  
    17  	"github.com/cockroachdb/errors"
    18  	_ "github.com/glebarez/go-sqlite" // required for sqlite3 rpm support
    19  	rpmdb "github.com/knqyf263/go-rpmdb/pkg"
    20  	"github.com/rs/zerolog/log"
    21  	"github.com/spf13/afero"
    22  	"go.mondoo.com/cnquery/providers-sdk/v1/inventory"
    23  	"go.mondoo.com/cnquery/providers/os/connection/shared"
    24  )
    25  
    26  const (
    27  	RpmPkgFormat = "rpm"
    28  )
    29  
    30  var RPM_REGEX = regexp.MustCompile(`^([\w-+]*)\s(\d*|\(none\)):([\w\d-+.:]+)\s([\w\d]*|\(none\))\s(.*)$`)
    31  
    32  // ParseRpmPackages parses output from:
    33  // rpm -qa --queryformat '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE} %{ARCH} %{SUMMARY}\n'
    34  func ParseRpmPackages(input io.Reader) []Package {
    35  	pkgs := []Package{}
    36  	scanner := bufio.NewScanner(input)
    37  	for scanner.Scan() {
    38  		line := scanner.Text()
    39  		m := RPM_REGEX.FindStringSubmatch(line)
    40  		if m != nil {
    41  			var version string
    42  			// only append the epoch if we have a non-zero value
    43  			if m[2] == "0" || strings.TrimSpace(m[2]) == "(none)" {
    44  				version = m[3]
    45  			} else {
    46  				version = m[2] + ":" + m[3]
    47  			}
    48  
    49  			arch := m[4]
    50  			// if no arch provided, remove it completely
    51  			if arch == "(none)" {
    52  				arch = ""
    53  			}
    54  
    55  			pkgs = append(pkgs, Package{
    56  				Name:        m[1],
    57  				Version:     version,
    58  				Arch:        arch,
    59  				Description: m[5],
    60  				Format:      RpmPkgFormat,
    61  			})
    62  		}
    63  	}
    64  	return pkgs
    65  }
    66  
    67  // RpmPkgManager is the package manager for Redhat, CentOS, Oracle, Photon and Suse
    68  // it support two modes: runtime where the rpm command is available and static analysis for images (e.g. container tar)
    69  // If the RpmPkgManager is used in static mode, it extracts the rpm database from the system and copies it to the local
    70  // filesystem to run a local rpm command to extract the data. The static analysis is always slower than using the running
    71  // one since more data need to copied. Therefore the runtime check should be preferred over the static analysis
    72  type RpmPkgManager struct {
    73  	conn          shared.Connection
    74  	platform      *inventory.Platform
    75  	staticChecked bool
    76  	static        bool
    77  }
    78  
    79  func (rpm *RpmPkgManager) Name() string {
    80  	return "Rpm Package Manager"
    81  }
    82  
    83  func (rpm *RpmPkgManager) Format() string {
    84  	return RpmPkgFormat
    85  }
    86  
    87  // determine if we running against a static image, where we cannot execute the rpm command
    88  // once executed, it caches its result to prevent the execution of the checks many times
    89  func (rpm *RpmPkgManager) isStaticAnalysis() bool {
    90  	if rpm.staticChecked {
    91  		return rpm.static
    92  	}
    93  
    94  	rpm.static = false
    95  
    96  	// check if the rpm command exists, e.g it is not available on tar backend
    97  	c, err := rpm.conn.RunCommand("command -v rpm")
    98  	if err != nil || c.ExitStatus != 0 {
    99  		log.Debug().Msg("mql[packages]> fallback to static rpm package manager")
   100  		rpm.static = true
   101  	}
   102  
   103  	// the root problem is that the docker transport (for running containers) cannot easily get the exit code so
   104  	// we cannot always rely on that, a running photon container return non-zero exit code but it will be -1 on the system
   105  	// we probably cannot fix this easily, see dockers approach:
   106  	// https://docs.docker.com/engine/reference/commandline/attach/#get-the-exit-code-of-the-containers-command
   107  	if c != nil {
   108  		rpmCmdPath, err := io.ReadAll(c.Stdout)
   109  		if err != nil || len(rpmCmdPath) == 0 {
   110  			rpm.static = true
   111  		}
   112  	}
   113  	rpm.staticChecked = true
   114  	return rpm.static
   115  }
   116  
   117  func (rpm *RpmPkgManager) List() ([]Package, error) {
   118  	if rpm.isStaticAnalysis() {
   119  		return rpm.staticList()
   120  	} else {
   121  		return rpm.runtimeList()
   122  	}
   123  }
   124  
   125  func (rpm *RpmPkgManager) Available() (map[string]PackageUpdate, error) {
   126  	if rpm.isStaticAnalysis() {
   127  		return rpm.staticAvailable()
   128  	} else {
   129  		return rpm.runtimeAvailable()
   130  	}
   131  }
   132  
   133  func (rpm *RpmPkgManager) queryFormat() string {
   134  	// this format should work everywhere
   135  	// fall-back to epoch instead of epochnum for 6 ish platforms, latest 6 platforms also support epochnum, but we
   136  	// save 1 call by not detecting the available keyword via rpm --querytags
   137  	format := "%{NAME} %{EPOCH}:%{VERSION}-%{RELEASE} %{ARCH} %{SUMMARY}\\n"
   138  
   139  	// ATTENTION: EPOCHNUM is only available since later version of rpm in RedHat 6 and Suse 12
   140  	// we can only expect if for rhel 7+, therefore we need to run an extra test
   141  	// be aware that this method is also used for non-redhat systems like suse
   142  	i, err := strconv.ParseInt(rpm.platform.Version, 0, 32)
   143  	if err == nil && (rpm.platform.Name == "centos" || rpm.platform.Name == "redhat") && i >= 7 {
   144  		format = "%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE} %{ARCH} %{SUMMARY}\\n"
   145  	}
   146  
   147  	return format
   148  }
   149  
   150  func (rpm *RpmPkgManager) runtimeList() ([]Package, error) {
   151  	command := fmt.Sprintf("rpm -qa --queryformat '%s'", rpm.queryFormat())
   152  	cmd, err := rpm.conn.RunCommand(command)
   153  	if err != nil {
   154  		return nil, errors.Wrap(err, "could not read package list")
   155  	}
   156  	return ParseRpmPackages(cmd.Stdout), nil
   157  }
   158  
   159  // fetch all available packages, is that working with centos 6?
   160  func (rpm *RpmPkgManager) runtimeAvailable() (map[string]PackageUpdate, error) {
   161  	// python script:
   162  	// import sys;sys.path.insert(0, "/usr/share/yum-cli");import cli;list = cli.YumBaseCli().returnPkgLists(["updates"]);
   163  	// print ''.join(["{\"name\":\""+x.name+"\", \"available\":\""+x.evr+"\",\"arch\":\""+x.arch+"\",\"repo\":\""+x.repo.id+"\"}\n" for x in list.updates]);
   164  	script := "python -c 'import sys;sys.path.insert(0, \"/usr/share/yum-cli\");import cli;list = cli.YumBaseCli().returnPkgLists([\"updates\"]);print \"\".join([ \"{\\\"name\\\":\\\"\"+x.name+\"\\\",\\\"available\\\":\\\"\"+x.evr+\"\\\",\\\"arch\\\":\\\"\"+x.arch+\"\\\",\\\"repo\\\":\\\"\"+x.repo.id+\"\\\"}\\n\" for x in list.updates]);'"
   165  
   166  	cmd, err := rpm.conn.RunCommand(script)
   167  	if err != nil {
   168  		log.Debug().Err(err).Msg("mql[packages]> could not read package updates")
   169  		return nil, errors.Wrap(err, "could not read package update list")
   170  	}
   171  	return ParseRpmUpdates(cmd.Stdout)
   172  }
   173  
   174  func (rpm *RpmPkgManager) staticList() ([]Package, error) {
   175  	rpmTmpDir, err := os.MkdirTemp(os.TempDir(), "mondoo-rpmdb")
   176  	if err != nil {
   177  		return nil, errors.Wrap(err, "could not create local temp directory")
   178  	}
   179  	log.Debug().Str("path", rpmTmpDir).Msg("mql[packages]> cache rpm library locally")
   180  	defer os.RemoveAll(rpmTmpDir)
   181  
   182  	fs := rpm.conn.FileSystem()
   183  	afs := &afero.Afero{Fs: fs}
   184  
   185  	// fetch rpm database file and store it in local tmp file
   186  	// iterate over file paths to check if one exists
   187  	files := []string{
   188  		"/usr/lib/sysimage/rpm/Packages",     // used on opensuse container
   189  		"/usr/lib/sysimage/rpm/Packages.db",  // used on SLES bci-base container
   190  		"/usr/lib/sysimage/rpm/rpmdb.sqlite", // used on fedora 36+ and photon4
   191  		"/var/lib/rpm/rpmdb.sqlite",          // used on fedora 33-35
   192  		"/var/lib/rpm/Packages",              // used on fedora 32
   193  	}
   194  	var tmpRpmDBFile string
   195  	var detectedPath string
   196  	for i := range files {
   197  		ok, err := afs.Exists(files[i])
   198  		if err == nil && ok {
   199  			splitPath := strings.Split(files[i], "/")
   200  			tmpRpmDBFile = filepath.Join(rpmTmpDir, splitPath[len(splitPath)-1])
   201  			detectedPath = files[i]
   202  			break
   203  		}
   204  	}
   205  
   206  	if len(detectedPath) == 0 {
   207  		return nil, errors.Wrap(err, "could not find rpm packages location on : "+rpm.platform.Name)
   208  	}
   209  	log.Debug().Str("path", detectedPath).Msg("found rpm packages location")
   210  
   211  	f, err := fs.Open(detectedPath)
   212  	if err != nil {
   213  		return nil, errors.Wrap(err, "could not fetch rpm package list")
   214  	}
   215  	defer f.Close()
   216  	fWriter, err := os.Create(tmpRpmDBFile)
   217  	if err != nil {
   218  		log.Error().Err(err).Msg("mql[packages]> could not create tmp file for rpm database")
   219  		return nil, errors.Wrap(err, "could not create local temp file")
   220  	}
   221  	defer fWriter.Close()
   222  	_, err = io.Copy(fWriter, f)
   223  	if err != nil {
   224  		log.Error().Err(err).Msg("mql[packages]> could not copy rpm to tmp file")
   225  		return nil, fmt.Errorf("could not cache rpm package list")
   226  	}
   227  
   228  	log.Debug().Str("rpmdb", rpmTmpDir).Msg("mql[packages]> cached rpm database locally")
   229  
   230  	packages := bytes.Buffer{}
   231  	db, err := rpmdb.Open(tmpRpmDBFile)
   232  	if err != nil {
   233  		return nil, err
   234  	}
   235  	pkgList, err := db.ListPackages()
   236  	if err != nil {
   237  		return nil, err
   238  	}
   239  	for _, pkg := range pkgList {
   240  		packages.WriteString(fmt.Sprintf("%s %d:%s-%s %s %s\n", pkg.Name, pkg.EpochNum(), pkg.Version, pkg.Release, pkg.Arch, pkg.Summary))
   241  	}
   242  
   243  	return ParseRpmPackages(&packages), nil
   244  }
   245  
   246  // TODO: Available() not implemented for RpmFileSystemManager
   247  // for now this is not an error since we can easily determine available packages
   248  func (rpm *RpmPkgManager) staticAvailable() (map[string]PackageUpdate, error) {
   249  	return map[string]PackageUpdate{}, nil
   250  }
   251  
   252  // SusePkgManager overwrites the normal RPM handler
   253  type SusePkgManager struct {
   254  	RpmPkgManager
   255  }
   256  
   257  func (spm *SusePkgManager) Available() (map[string]PackageUpdate, error) {
   258  	if spm.isStaticAnalysis() {
   259  		return spm.staticAvailable()
   260  	}
   261  	cmd, err := spm.conn.RunCommand("zypper -n --xmlout list-updates")
   262  	if err != nil {
   263  		log.Debug().Err(err).Msg("mql[packages]> could not read package updates")
   264  		return nil, fmt.Errorf("could not read package update list")
   265  	}
   266  	return ParseZypperUpdates(cmd.Stdout)
   267  }