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 }