github.com/google/osv-scalibr@v0.4.1/artifact/image/image.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package image provides functionality to scan a container image by layers for software
    16  // inventory.
    17  package image
    18  
    19  import (
    20  	"fmt"
    21  	"os"
    22  	"strings"
    23  
    24  	"github.com/google/go-containerregistry/pkg/name"
    25  	v1 "github.com/google/go-containerregistry/pkg/v1"
    26  	"github.com/google/go-containerregistry/pkg/v1/remote"
    27  	"github.com/google/osv-scalibr/artifact/image/require"
    28  	"github.com/google/osv-scalibr/artifact/image/unpack"
    29  	"github.com/opencontainers/go-digest"
    30  
    31  	scalibrfs "github.com/google/osv-scalibr/fs"
    32  )
    33  
    34  // Layer is a filesystem derived from a container layer that can be scanned for software inventory.
    35  // It also holds metadata about the container layer such as whether it is empty, its diffID, index,
    36  // and command.
    37  type Layer interface {
    38  	// FS outputs a filesystem that consist of the files found in the layer. This includes files that
    39  	// were added or modified. Whiteout files are also included in the filesystem if files or
    40  	// directories from previous layers were removed.
    41  	FS() scalibrfs.FS
    42  	// IsEmpty signifies whether the layer is empty. This should correspond with an empty filesystem
    43  	// produced by the FS method.
    44  	IsEmpty() bool
    45  	// DiffID is the hash of the uncompressed layer. Will be an empty string if the layer is empty.
    46  	DiffID() digest.Digest
    47  	// Command is the specific command that produced the layer.
    48  	Command() string
    49  }
    50  
    51  // ChainLayer is a filesystem derived from container layers that can be scanned for software
    52  // inventory. It holds all the files found in layer 0, layer 1, ..., layer n (where n is the layer
    53  // index). It also holds metadata about the latest container layer such as whether it is empty, its
    54  // diffID, command, and index.
    55  type ChainLayer interface {
    56  	// FS output an filesystem that consist of the files found in the layer n and all previous layers
    57  	// (layer 0, layer 1, ..., layer n).
    58  	FS() scalibrfs.FS
    59  	// Index is the index of the latest layer in the layer chain.
    60  	Index() int
    61  	// ChainID is the layer chain ID (sha256 hash) of the layer in the container image.
    62  	// https://github.com/opencontainers/image-spec/blob/main/config.md#layer-chainid
    63  	ChainID() digest.Digest
    64  	// Layer is the latest layer in the layer chain.
    65  	Layer() Layer
    66  }
    67  
    68  // Image is a container image that can be scanned for software inventory. It is composed of a set of
    69  // layers that can be scanned for software inventory.
    70  type Image interface {
    71  	// Layers returns the layers of the image.
    72  	Layers() ([]Layer, error)
    73  	// ChainLayers returns the chain layers of the image.
    74  	ChainLayers() ([]ChainLayer, error)
    75  	// FS returns a SCALIBR compliant filesystem that represents the image.
    76  	FS() scalibrfs.FS
    77  }
    78  
    79  // V1ImageFromRemoteName creates a v1.Image from a remote container image name.
    80  func V1ImageFromRemoteName(imageName string, imageOptions ...remote.Option) (v1.Image, error) {
    81  	imageName = strings.TrimPrefix(imageName, "https://")
    82  	var image v1.Image
    83  	if strings.Contains(imageName, "@") {
    84  		// Pull from a digest name.
    85  		ref, err := name.NewDigest(strings.TrimPrefix(imageName, "https://"))
    86  		if err != nil {
    87  			return nil, fmt.Errorf("unable to parse digest: %w", err)
    88  		}
    89  		descriptor, err := remote.Get(ref, imageOptions...)
    90  		if err != nil {
    91  			return nil, fmt.Errorf("couldn’t pull remote image %s: %w", ref, err)
    92  		}
    93  		image, err = descriptor.Image()
    94  		if err != nil {
    95  			return nil, fmt.Errorf("couldn’t parse image manifest %s: %w", ref, err)
    96  		}
    97  	} else {
    98  		// Pull from a tag.
    99  		tag, err := name.NewTag(strings.TrimPrefix(imageName, "https://"))
   100  		if err != nil {
   101  			return nil, fmt.Errorf("unable to parse image reference: %w", err)
   102  		}
   103  		image, err = remote.Image(tag, imageOptions...)
   104  		if err != nil {
   105  			return nil, fmt.Errorf("couldn’t pull remote image %s: %w", tag, err)
   106  		}
   107  	}
   108  	return image, nil
   109  }
   110  
   111  // NewFromRemoteName pulls a remote container and creates a
   112  // SCALIBR filesystem for scanning it.
   113  func NewFromRemoteName(imageName string, imageOptions ...remote.Option) (scalibrfs.FS, error) {
   114  	image, err := V1ImageFromRemoteName(imageName, imageOptions...)
   115  	if err != nil {
   116  		return nil, fmt.Errorf("failed to load image from remote name %q: %w", imageName, err)
   117  	}
   118  	return NewFromImage(image)
   119  }
   120  
   121  // NewFromImage creates a SCALIBR filesystem for scanning a container
   122  // from its image descriptor.
   123  func NewFromImage(image v1.Image) (scalibrfs.FS, error) {
   124  	outDir, err := os.MkdirTemp(os.TempDir(), "scalibr-container-")
   125  	if err != nil {
   126  		return nil, fmt.Errorf("couldn’t create tmp dir for image: %w", err)
   127  	}
   128  	// Squash the image's final layer into a directory.
   129  	cfg := &unpack.UnpackerConfig{
   130  		SymlinkResolution:  unpack.SymlinkRetain,
   131  		SymlinkErrStrategy: unpack.SymlinkErrLog,
   132  		MaxPass:            unpack.DefaultMaxPass,
   133  		MaxFileBytes:       unpack.DefaultMaxFileBytes,
   134  		Requirer:           &require.FileRequirerAll{},
   135  	}
   136  	unpacker, err := unpack.NewUnpacker(cfg)
   137  	if err != nil {
   138  		return nil, fmt.Errorf("failed to create image unpacker: %w", err)
   139  	}
   140  	if err = unpacker.UnpackSquashed(outDir, image); err != nil {
   141  		return nil, fmt.Errorf("failed to unpack image into directory %q: %w", outDir, err)
   142  	}
   143  	return scalibrfs.DirFS(outDir), nil
   144  }