github.com/devseccon/trivy@v0.47.1-0.20231123133102-bd902a0bd996/pkg/fanal/image/daemon/image.go (about) 1 package daemon 2 3 import ( 4 "context" 5 "io" 6 "os" 7 "strings" 8 "sync" 9 "time" 10 11 "github.com/docker/docker/api/types" 12 "github.com/docker/docker/api/types/container" 13 dimage "github.com/docker/docker/api/types/image" 14 v1 "github.com/google/go-containerregistry/pkg/v1" 15 "github.com/google/go-containerregistry/pkg/v1/tarball" 16 "github.com/samber/lo" 17 "golang.org/x/xerrors" 18 19 "github.com/devseccon/trivy/pkg/log" 20 ) 21 22 type Image interface { 23 v1.Image 24 RepoTags() []string 25 RepoDigests() []string 26 } 27 28 var mu sync.Mutex 29 30 type opener func() (v1.Image, error) 31 32 type imageSave func(context.Context, []string) (io.ReadCloser, error) 33 34 func imageOpener(ctx context.Context, ref string, f *os.File, imageSave imageSave) opener { 35 return func() (v1.Image, error) { 36 // Store the tarball in local filesystem and return a new reader into the bytes each time we need to access something. 37 rc, err := imageSave(ctx, []string{ref}) 38 if err != nil { 39 return nil, xerrors.Errorf("unable to export the image: %w", err) 40 } 41 defer rc.Close() 42 43 if _, err = io.Copy(f, rc); err != nil { 44 return nil, xerrors.Errorf("failed to copy the image: %w", err) 45 } 46 defer f.Close() 47 48 img, err := tarball.ImageFromPath(f.Name(), nil) 49 if err != nil { 50 return nil, xerrors.Errorf("failed to initialize the struct from the temporary file: %w", err) 51 } 52 53 return img, nil 54 } 55 } 56 57 // image is a wrapper for github.com/google/go-containerregistry/pkg/v1/daemon.Image 58 // daemon.Image loads the entire image into the memory at first, 59 // but it doesn't need to load it if the information is already in the cache, 60 // To avoid entire loading, this wrapper uses ImageInspectWithRaw and checks image ID and layer IDs. 61 type image struct { 62 v1.Image 63 opener opener 64 inspect types.ImageInspect 65 history []v1.History 66 } 67 68 // populateImage initializes an "image" struct. 69 // This method is called by some goroutines at the same time. 70 // To prevent multiple heavy initializations, the lock is necessary. 71 func (img *image) populateImage() (err error) { 72 mu.Lock() 73 defer mu.Unlock() 74 75 // img.Image is already initialized, so we don't have to do it again. 76 if img.Image != nil { 77 return nil 78 } 79 80 img.Image, err = img.opener() 81 if err != nil { 82 return xerrors.Errorf("unable to open: %w", err) 83 } 84 85 return nil 86 } 87 88 func (img *image) ConfigName() (v1.Hash, error) { 89 return v1.NewHash(img.inspect.ID) 90 } 91 92 func (img *image) ConfigFile() (*v1.ConfigFile, error) { 93 if len(img.inspect.RootFS.Layers) == 0 { 94 // Podman doesn't return RootFS... 95 return img.configFile() 96 } 97 98 nonEmptyLayerCount := lo.CountBy(img.history, func(history v1.History) bool { 99 return !history.EmptyLayer 100 }) 101 102 if len(img.inspect.RootFS.Layers) != nonEmptyLayerCount { 103 // In cases where empty layers are not correctly determined from the history API. 104 // There are some edge cases where we cannot guess empty layers well. 105 return img.configFile() 106 } 107 108 diffIDs, err := img.diffIDs() 109 if err != nil { 110 return nil, xerrors.Errorf("unable to get diff IDs: %w", err) 111 } 112 113 created, err := time.Parse(time.RFC3339Nano, img.inspect.Created) 114 if err != nil { 115 return nil, xerrors.Errorf("failed parsing created %s: %w", img.inspect.Created, err) 116 } 117 118 return &v1.ConfigFile{ 119 Architecture: img.inspect.Architecture, 120 Author: img.inspect.Author, 121 Container: img.inspect.Container, 122 Created: v1.Time{Time: created}, 123 DockerVersion: img.inspect.DockerVersion, 124 Config: img.imageConfig(img.inspect.Config), 125 History: img.history, 126 OS: img.inspect.Os, 127 RootFS: v1.RootFS{ 128 Type: img.inspect.RootFS.Type, 129 DiffIDs: diffIDs, 130 }, 131 }, nil 132 } 133 134 func (img *image) configFile() (*v1.ConfigFile, error) { 135 log.Logger.Debug("Saving the container image to a local file to obtain the image config...") 136 137 // Need to fall back into expensive operations like "docker save" 138 // because the config file cannot be generated properly from container engine API for some reason. 139 if err := img.populateImage(); err != nil { 140 return nil, xerrors.Errorf("unable to populate: %w", err) 141 } 142 return img.Image.ConfigFile() 143 } 144 145 func (img *image) LayerByDiffID(h v1.Hash) (v1.Layer, error) { 146 if err := img.populateImage(); err != nil { 147 return nil, xerrors.Errorf("unable to populate: %w", err) 148 } 149 return img.Image.LayerByDiffID(h) 150 } 151 152 func (img *image) RawConfigFile() ([]byte, error) { 153 if err := img.populateImage(); err != nil { 154 return nil, xerrors.Errorf("unable to populate: %w", err) 155 } 156 return img.Image.RawConfigFile() 157 } 158 159 func (img *image) RepoTags() []string { 160 return img.inspect.RepoTags 161 } 162 163 func (img *image) RepoDigests() []string { 164 return img.inspect.RepoDigests 165 } 166 167 func (img *image) diffIDs() ([]v1.Hash, error) { 168 var diffIDs []v1.Hash 169 for _, l := range img.inspect.RootFS.Layers { 170 h, err := v1.NewHash(l) 171 if err != nil { 172 return nil, xerrors.Errorf("invalid hash %s: %w", l, err) 173 } 174 diffIDs = append(diffIDs, h) 175 } 176 return diffIDs, nil 177 } 178 179 func (img *image) imageConfig(config *container.Config) v1.Config { 180 if config == nil { 181 return v1.Config{} 182 } 183 184 c := v1.Config{ 185 AttachStderr: config.AttachStderr, 186 AttachStdin: config.AttachStdin, 187 AttachStdout: config.AttachStdout, 188 Cmd: config.Cmd, 189 Domainname: config.Domainname, 190 Entrypoint: config.Entrypoint, 191 Env: config.Env, 192 Hostname: config.Hostname, 193 Image: config.Image, 194 Labels: config.Labels, 195 OnBuild: config.OnBuild, 196 OpenStdin: config.OpenStdin, 197 StdinOnce: config.StdinOnce, 198 Tty: config.Tty, 199 User: config.User, 200 Volumes: config.Volumes, 201 WorkingDir: config.WorkingDir, 202 ArgsEscaped: config.ArgsEscaped, 203 NetworkDisabled: config.NetworkDisabled, 204 MacAddress: config.MacAddress, 205 StopSignal: config.StopSignal, 206 Shell: config.Shell, 207 } 208 209 if config.Healthcheck != nil { 210 c.Healthcheck = &v1.HealthConfig{ 211 Test: config.Healthcheck.Test, 212 Interval: config.Healthcheck.Interval, 213 Timeout: config.Healthcheck.Timeout, 214 StartPeriod: config.Healthcheck.StartPeriod, 215 Retries: config.Healthcheck.Retries, 216 } 217 } 218 219 if len(config.ExposedPorts) > 0 { 220 c.ExposedPorts = make(map[string]struct{}) 221 for port := range c.ExposedPorts { 222 c.ExposedPorts[port] = struct{}{} 223 } 224 } 225 226 return c 227 } 228 229 func configHistory(dhistory []dimage.HistoryResponseItem) []v1.History { 230 // Fill only required metadata 231 var history []v1.History 232 233 for i := len(dhistory) - 1; i >= 0; i-- { 234 h := dhistory[i] 235 history = append(history, v1.History{ 236 Created: v1.Time{ 237 Time: time.Unix(h.Created, 0).UTC(), 238 }, 239 CreatedBy: h.CreatedBy, 240 Comment: h.Comment, 241 EmptyLayer: emptyLayer(h), 242 }) 243 } 244 return history 245 } 246 247 // emptyLayer tries to determine if the layer is empty from the history API, but may return a wrong result. 248 // The non-empty layers will be compared to diffIDs later so that results can be validated. 249 func emptyLayer(history dimage.HistoryResponseItem) bool { 250 if history.Size != 0 { 251 return false 252 } 253 createdBy := strings.TrimSpace(strings.TrimLeft(history.CreatedBy, "/bin/sh -c #(nop)")) 254 // This logic is taken from https://github.com/moby/buildkit/blob/2942d13ff489a2a49082c99e6104517e357e53ad/frontend/dockerfile/dockerfile2llb/convert.go 255 if strings.HasPrefix(createdBy, "ENV") || 256 strings.HasPrefix(createdBy, "MAINTAINER") || 257 strings.HasPrefix(createdBy, "LABEL") || 258 strings.HasPrefix(createdBy, "CMD") || 259 strings.HasPrefix(createdBy, "ENTRYPOINT") || 260 strings.HasPrefix(createdBy, "HEALTHCHECK") || 261 strings.HasPrefix(createdBy, "EXPOSE") || 262 strings.HasPrefix(createdBy, "USER") || 263 strings.HasPrefix(createdBy, "VOLUME") || 264 strings.HasPrefix(createdBy, "STOPSIGNAL") || 265 strings.HasPrefix(createdBy, "SHELL") || 266 strings.HasPrefix(createdBy, "ARG") { 267 return true 268 } 269 // buildkit layers with "WORKDIR /" command are empty, 270 if strings.HasPrefix(history.Comment, "buildkit.dockerfile") { 271 if createdBy == "WORKDIR /" { 272 return true 273 } 274 } else if strings.HasPrefix(createdBy, "WORKDIR") { // layers build with docker and podman, WORKDIR command is always empty layer. 275 return true 276 } 277 // The following instructions could reach here: 278 // - "ADD" 279 // - "COPY" 280 // - "RUN" 281 // - "RUN" may not include even 'RUN' prefix 282 // e.g. '/bin/sh -c mkdir test ' 283 // - "WORKDIR", which doesn't meet the above conditions 284 return false 285 }