go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/providers/os/connection/local/statutil/stat.go (about) 1 // Copyright (c) Mondoo, Inc. 2 // SPDX-License-Identifier: BUSL-1.1 3 4 package statutil 5 6 import ( 7 "io" 8 "io/fs" 9 "os" 10 "path/filepath" 11 "regexp" 12 "strconv" 13 "strings" 14 "syscall" 15 "time" 16 17 "github.com/cockroachdb/errors" 18 "github.com/rs/zerolog/log" 19 "go.mondoo.com/cnquery/providers/os/connection/shared" 20 ) 21 22 type CommandRunner interface { 23 RunCommand(command string) (*shared.Command, error) 24 } 25 26 var ESCAPEREGEX = regexp.MustCompile(`[^\w@%+=:,./-]`) 27 28 func ShellEscape(s string) string { 29 if len(s) == 0 { 30 return "''" 31 } 32 if ESCAPEREGEX.MatchString(s) { 33 return "'" + strings.Replace(s, "'", "'\"'\"'", -1) + "'" 34 } 35 36 return s 37 } 38 39 func New(cmdRunner CommandRunner) *statHelper { 40 return &statHelper{ 41 commandRunner: cmdRunner, 42 } 43 } 44 45 // Stat helper implements the stat command for various unix systems 46 // since this helper is used by transports itself, we cannot rely on the 47 // platform detection mechanism (since it may rely on stat to determine the system) 48 // therefore we implement the minimum required to detect the right stat parser 49 type statHelper struct { 50 commandRunner CommandRunner 51 detected bool 52 isUnix bool 53 } 54 55 var bsdunix = map[string]bool{ 56 "openbsd": true, 57 "dragonfly": true, 58 "freebsd": true, 59 "netbsd": true, 60 "darwin": true, // use bsd stat for macOS 61 } 62 63 func (s *statHelper) Stat(name string) (os.FileInfo, error) { 64 // detect stat version 65 if !s.detected { 66 cmd, err := s.commandRunner.RunCommand("uname -s") 67 if err != nil { 68 log.Debug().Err(err).Str("file", name).Msg("could not detect platform for file stat") 69 return nil, err 70 } 71 72 data, err := io.ReadAll(cmd.Stdout) 73 if err != nil { 74 return nil, err 75 } 76 77 // only switch to unix if we properly detected it, otherwise fallback to linux 78 val := strings.ToLower(strings.TrimSpace(string(data))) 79 80 isUnix, ok := bsdunix[val] 81 if ok && isUnix { 82 s.isUnix = true 83 } 84 s.detected = true 85 } 86 87 if s.isUnix { 88 return s.unix(name) 89 } 90 return s.linux(name) 91 } 92 93 func (s *statHelper) linux(name string) (os.FileInfo, error) { 94 path := ShellEscape(name) 95 96 // check if file exists 97 cmd, err := s.commandRunner.RunCommand("test -e " + path) 98 if err != nil || cmd.ExitStatus != 0 { 99 return nil, os.ErrNotExist 100 } 101 102 var sb strings.Builder 103 sb.WriteString("stat -L ") 104 sb.WriteString(path) 105 sb.WriteString(" -c '%s.%f.%u.%g.%X.%Y.%C'") 106 107 // NOTE: handling the exit code here does not work for all cases 108 // sometimes stat returns something like: failed to get security context of '/etc/ssh/sshd_config': No data available 109 // Therefore we continue after this command and try to parse the result and focus on making the parsing more robust 110 command := sb.String() 111 cmd, err = s.commandRunner.RunCommand(command) 112 113 // we get stderr content in cases where we could not gather the security context via failed to get security context of 114 // it could also include: No such file or directory 115 if err != nil { 116 log.Debug().Str("path", path).Str("command", command).Err(err).Send() 117 } 118 119 if cmd == nil { 120 return nil, errors.New("could not parse file stat: " + path) 121 } 122 123 data, err := io.ReadAll(cmd.Stdout) 124 if err != nil { 125 return nil, err 126 } 127 128 statsData := strings.Split(strings.TrimSpace(string(data)), ".") 129 if len(statsData) != 7 { 130 log.Debug().Str("path", path).Msg("could not parse file stat information") 131 // TODO: we may need to parse the returning error to better distinguish between a real error and file not found 132 // if we are going to check for file not found, we probably run into the issue that the error message is returned in 133 // multiple languages 134 return nil, errors.New("could not parse file stat: " + path) 135 } 136 137 // Note: The SElinux context may not be supported by stats on all OSs. 138 // For example: Alpine does not support it, resulting in statsData[6] == "C" 139 140 size, err := strconv.Atoi(statsData[0]) 141 if err != nil { 142 return nil, errors.Wrap(err, "could not stat "+name) 143 } 144 145 uid, err := strconv.ParseInt(statsData[2], 10, 64) 146 if err != nil { 147 return nil, errors.Wrap(err, "could not stat "+name) 148 } 149 150 gid, err := strconv.ParseInt(statsData[3], 10, 64) 151 if err != nil { 152 return nil, errors.Wrap(err, "could not stat "+name) 153 } 154 155 mask, err := strconv.ParseUint(statsData[1], 16, 32) 156 if err != nil { 157 return nil, errors.Wrap(err, "could not stat "+name) 158 } 159 160 mtime, err := strconv.ParseInt(statsData[4], 10, 64) 161 if err != nil { 162 return nil, errors.Wrap(err, "could not stat "+name) 163 } 164 165 // extract file modes 166 mapMode := toFileMode(mask) 167 168 return &shared.FileInfo{ 169 FName: filepath.Base(path), 170 FSize: int64(size), 171 FMode: mapMode, 172 FIsDir: mapMode.IsDir(), 173 FModTime: time.Unix(mtime, 0), 174 Uid: uid, 175 Gid: gid, 176 }, nil 177 } 178 179 func (s *statHelper) unix(name string) (os.FileInfo, error) { 180 lstat := "-L" 181 format := "-f" 182 path := ShellEscape(name) 183 184 var sb strings.Builder 185 sb.WriteString("stat ") 186 sb.WriteString(lstat) 187 sb.WriteString(" ") 188 sb.WriteString(format) 189 sb.WriteString(" '%z:%p:%u:%g:%a:%m'") 190 sb.WriteString(" ") 191 sb.WriteString(path) 192 193 cmd, err := s.commandRunner.RunCommand(sb.String()) 194 if err != nil { 195 log.Debug().Err(err).Send() 196 } 197 198 data, err := io.ReadAll(cmd.Stdout) 199 if err != nil { 200 return nil, err 201 } 202 203 statsData := strings.Split(string(data), ":") 204 if len(statsData) != 6 { 205 log.Error().Str("name", name).Msg("could not parse file stat information") 206 // TODO: there are likely cases where the file exist but we could still not parse it 207 return nil, os.ErrNotExist 208 } 209 210 size, err := strconv.Atoi(statsData[0]) 211 if err != nil { 212 return nil, errors.Wrap(err, "could not stat "+name) 213 } 214 215 uid, err := strconv.ParseInt(statsData[2], 10, 64) 216 if err != nil { 217 return nil, errors.Wrap(err, "could not stat "+name) 218 } 219 220 gid, err := strconv.ParseInt(statsData[3], 10, 64) 221 if err != nil { 222 return nil, errors.Wrap(err, "could not stat "+name) 223 } 224 225 // NOTE: the base is 8 instead of 16 on linux systems 226 mask, err := strconv.ParseUint(statsData[1], 8, 32) 227 if err != nil { 228 return nil, errors.Wrap(err, "could not stat "+name) 229 } 230 231 mode := toFileMode(mask) 232 233 mtime, err := strconv.ParseInt(statsData[4], 10, 64) 234 if err != nil { 235 return nil, errors.Wrap(err, "could not stat "+name) 236 } 237 238 return &shared.FileInfo{ 239 FName: filepath.Base(path), 240 FSize: int64(size), 241 FMode: mode, 242 FIsDir: mode.IsDir(), 243 FModTime: time.Unix(mtime, 0), 244 Uid: uid, 245 Gid: gid, 246 }, nil 247 } 248 249 const ( 250 S_IFMT = 0o170000 251 S_IFBLK = 0o60000 252 S_IFCHR = 0o20000 253 S_IFDIR = 0o40000 254 S_IFIFO = 10000 255 S_ISUID = 0o4000 256 S_ISGID = 0o2000 257 S_ISVTX = 0o1000 258 ) 259 260 func toFileMode(mask uint64) os.FileMode { 261 mode := os.FileMode(uint32(mask) & 0o0777) 262 263 // taken from https://github.com/golang/go/blob/2ebe77a2fda1ee9ff6fd9a3e08933ad1ebaea039/src/os/stat_linux.go 264 switch mask & S_IFMT { 265 case S_IFBLK: 266 mode |= fs.ModeDevice 267 case S_IFCHR: 268 mode |= fs.ModeDevice | fs.ModeCharDevice 269 case S_IFDIR: 270 mode |= fs.ModeDir 271 case S_IFIFO: 272 mode |= fs.ModeNamedPipe 273 case syscall.S_IFLNK: 274 mode |= fs.ModeSymlink 275 case syscall.S_IFREG: 276 // nothing to do 277 case syscall.S_IFSOCK: 278 mode |= fs.ModeSocket 279 } 280 if mask&syscall.S_ISGID != 0 { 281 mode |= fs.ModeSetgid 282 } 283 if mask&syscall.S_ISUID != 0 { 284 mode |= fs.ModeSetuid 285 } 286 if mask&syscall.S_ISVTX != 0 { 287 mode |= fs.ModeSticky 288 } 289 return mode 290 }