一 背景
容器镜像在我们日常的开发工作中占据着极其重要的位置。通常情况下我们是将应用程序打包到容器镜像并上传到镜像仓库中,在生产环境将其拉取下来。然后用 docker/containerd 等容器运行时将镜像启动,开始执行应用。但是对于一些运维平台来说,对于一个镜像制品本身的扫描和分析才是真正的关注点。本文简单介绍下如何在代码中解析一个容器镜像。
二 go-containerregistry
go-containerregistry 是 google 公司的一个开源项目,它提供了一个对镜像的操作接口,这个接口背后的资源可以是 镜像仓库的远程资源,镜像的tar包,甚至是 docker daemon 进程。下面我们就简单介绍下如何使用这个项目来完成我们的目标—— 在代码中解析镜像。
除了对外提供了三方包,该项目里面还提供了 crane (与远端镜像交互的客户端)gcrane (与 gcr 交互的客户端)。
三 基本接口
1 镜像基本概念
在介绍具体接口之间先介绍几个简单概念
ImageIndex, 根据 OCI 规范,是为了兼容多架构(amd64, arm64)镜像而创造出来的数据结构, 我们可以在一个ImageIndex 里面关联多个镜像,使用同一个镜像tag,客户端(docker,ctr)会根据客户端所在的操作系统的基础架构拉取对应架构的镜像下来
Image Manifest 基本上对应了一个镜像,里面包含了一个镜像的所有layers digest,客户端拉取镜像的时候一般都是先获取manifest 文件,在根据 manifest 文件里面的内容拉取镜像各个层(tar+gzip)
Image Config 跟 ImageManifest 是一一对应的关系,Image Config 主要包含一些 镜像的基本配置,例如 创建时间,作者,该镜像的基础架构,镜像层的 diffID(未压缩的 ChangeSet),ChainID 之类的信息。一般在宿主机上执行 docker image 看到的ImageID就是 ImageConfig 的hash值。
layer 就是镜像层,镜像层信息不包含任何的运行时信息(环境变量等)只包含文件系统的信息。镜像是通过最底层 rootfs 加上各层的 changeset(对上一层的 add, update, delete 操作)组合而成的。
layer diffid 是未压缩的层的hash值,常见于 本地环境,使用 看到的便是diffid。因为客户端一般下载 ImageConfig, ImageConfig 里面是引用的diffid。
layer digest 是压缩后的层的hash值,常见于镜像仓库 使用 看到的layers 一般都是 digest. 因为 manifest 引用都是 layer digest。
两者没有可以直接转换的方式,目前的唯一方式就是按照顺序来对应。
用一张图来总结一下。
// ImageIndex 定义与 OCI ImageIndex 交互的接口 type ImageIndex interface { // 返回当前 imageIndex 的 MediaType MediaType() (types.MediaType, error) // 返回这个 ImageIndex manifest 的 sha256值。 Digest() (Hash, error) // 返回这个 ImageIndex manifest 的大小 Size() (int64, error) // 返回这个 ImageIndex 的 manifest 结构 IndexManifest() (*IndexManifest, error) // 返回这个 ImageIndex 的 manifest 字节数组 RawManifest() ([]byte, error) // 返回这个 ImageIndex 引用的 Image Image(Hash) (Image, error) // 返回这个 ImageIndex 引用的 ImageIndex ImageIndex(Hash) (ImageIndex, error) } // Image 定义了与 OCI Image 交互的接口 type Image interface { // 返回了当前镜像的所有层级, 最老/最基础的层在数组的前面,最上面/最新的层在数组的后面 Layers() ([]Layer, error) // 返回当前 image 的 MediaType MediaType() (types.MediaType, error) // 返回这个 Image manifest 的大小 Size() (int64, error) // 返回这个镜像 ConfigFile 的hash值,也是这个镜像的 ImageID ConfigName() (Hash, error) // 返回这个镜像的 ConfigFile ConfigFile() (*ConfigFile, error) // 返回这个镜像的 ConfigFile 的字节数组 RawConfigFile() ([]byte, error) // 返回这个Image Manifest 的sha256 值 Digest() (Hash, error) // 返回这个Image Manifest Manifest() (*Manifest, error) // 返回 ImageManifest 的bytes数组 RawManifest() ([]byte, error) // 返回这个镜像中的某一层layer, 根据 digest(压缩后的hash值) 来查找 LayerByDigest(Hash) (Layer, error) // 返回这个镜像中的某一层layer, 根据 diffid (未压缩的hash值) 来查找 LayerByDiffID(Hash) (Layer, error) } // Layer 定义了访问 OCI Image 特定 Layer 的接口 type Layer interface { // 返回了压缩后的layer的sha256 值 Digest() (Hash, error) // 返回了 未压缩的layer 的sha256值. DiffID() (Hash, error) // 返回了压缩后的镜像层 Compressed() (io.ReadCloser, error) // 返回了未压缩的镜像层 Uncompressed() (io.ReadCloser, error) // 返回了压缩后镜像层的大小 Size() (int64, error) // 返回当前 layer 的 MediaType MediaType() (types.MediaType, error) }
相关接口功能已在注释中说明,不再赘述。
四 获取镜像相关元信息
我们以 remote 方式(拉取远程镜像) 举例说明下如何使用。
package main import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" ) func main() { ref, err := name.ParseReference("xxx") if err != nil { panic(err) } tryRemote(context.TODO(), ref, GetDockerOption()) if err != nil { panic(err) } // do stuff with img } type DockerOption struct { // Auth UserName string Password string // RegistryToken is a bearer token to be sent to a registry RegistryToken string // ECR AwsAccessKey string AwsSecretKey string AwsSessionToken string AwsRegion string // GCP GcpCredPath string InsecureSkipTLSVerify bool NonSSL bool SkipPing bool // this is ignored now Timeout time.Duration } func GetDockerOption() (types.DockerOption, error) { cfg := DockerConfig{} if err := env.Parse(&cfg); err != nil { return types.DockerOption{}, fmt.Errorf("unable to parse environment variables: %w", err) } return types.DockerOption{ UserName: cfg.UserName, Password: cfg.Password, RegistryToken: cfg.RegistryToken, InsecureSkipTLSVerify: cfg.Insecure, NonSSL: cfg.NonSSL, }, nil } func tryRemote(ctx context.Context, ref name.Reference, option types.DockerOption) (v1.Image, extender, error) { var remoteOpts []remote.Option if option.InsecureSkipTLSVerify { t := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } remoteOpts = append(remoteOpts, remote.WithTransport(t)) } domain := ref.Context().RegistryStr() auth := token.GetToken(ctx, domain, option) if auth.Username != "" && auth.Password != "" { remoteOpts = append(remoteOpts, remote.WithAuth(&auth)) } else if option.RegistryToken != "" { bearer := authn.Bearer{Token: option.RegistryToken} remoteOpts = append(remoteOpts, remote.WithAuth(&bearer)) } else { remoteOpts = append(remoteOpts, remote.WithAuthFromKeychain(authn.DefaultKeychain)) } desc, err := remote.Get(ref, remoteOpts...) if err != nil { return nil, nil, err } img, err := desc.Image() if err != nil { return nil, nil, err } // Return v1.Image if the image is found in Docker Registry return img, remoteExtender{ ref: implicitReference{ref: ref}, descriptor: desc, }, nil }
执行完 tryRemote 代码之后就可以获取 Image 对象的实例,进而对这个实例进行操作。明确以下几个关键点
remote.Get() 方法只会实际拉取镜像的manifestList/manifest,并不会拉取整个镜像。
desc.Image() 方法会判断 remote.Get() 返回的媒体类型。如果是镜像的话直接返回一个 Image interface, 如果是 manifest list 的情况会解析当前宿主机的架构,并且返回指定架构对应的镜像。 同样这里并不会拉取镜像。
所有的数据都是lazy load。只有需要的时候才会去获取。
五 读取镜像中系统软件的信息
通过上面的接口定义可知,我们可以通过 Image.LayerByDiffID(Hash) (Layer, error) 获取一个 layer 对象, 获取了layer对象之后我们可以调用 layer.Uncompressed() 方法获取一个未被压缩的层的 io.Reader , 也就是一个 tar file。
// tarOnceOpener 读取文件一次并共享内容,以便分析器可以共享数据 func tarOnceOpener(r io.Reader) func() ([]byte, error) { var once sync.Once var b []byte var err error return func() ([]byte, error) { once.Do(func() { b, err = ioutil.ReadAll(r) }) if err != nil { return nil, xerrors.Errorf("unable to read tar file: %w", err) } return b, nil } } // 该方法主要是遍历整个 io stream,首先解析出文件的元信息 (path, prefix,suffix), 然后调用 analyzeFn 方法解析文件内容 func WalkLayerTar(layer io.Reader, analyzeFn WalkFunc) ([]string, []string, error) { var opqDirs, whFiles []string var result *AnalysisResult tr := tar.NewReader(layer) opq := ".wh..wh..opq" wh := ".wh." for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return nil, nil, xerrors.Errorf("failed to extract the archive: %w", err) } filePath := hdr.Name filePath = strings.TrimLeft(filepath.Clean(filePath), "/") fileDir, fileName := filepath.Split(filePath) // e.g. etc/.wh..wh..opq if opq == fileName { opqDirs = append(opqDirs, fileDir) continue } // etc/.wh.hostname if strings.HasPrefix(fileName, wh) { name := strings.TrimPrefix(fileName, wh) fpath := filepath.Join(fileDir, name) whFiles = append(whFiles, fpath) continue } if hdr.Typeflag == tar.TypeSymlink || hdr.Typeflag == tar.TypeLink || hdr.Typeflag == tar.TypeReg { analyzeFn(filePath, hdr.FileInfo(), tarOnceOpener(tr), result) if err != nil { return nil, nil, xerrors.Errorf("failed to analyze file: %w", err) } } } return opqDirs, whFiles, nil } // 调用不同的driver 对同一个文件进行解析 func analyzeFn(filePath string, info os.FileInfo, opener analyzer.Opener,result *AnalysisResult) error { if info.IsDir() { return nil, nil } var wg sync.WaitGroup for _, d := range drivers { // filepath extracted from tar file doesn't have the prefix "/" if !d.Required(strings.TrimLeft(filePath, "/"), info) { continue } b, err := opener() if err != nil { return nil, xerrors.Errorf("unable to open a file (%s): %w", filePath, err) } if err = limit.Acquire(ctx, 1); err != nil { return nil, xerrors.Errorf("semaphore acquire: %w", err) } wg.Add(1) go func(a analyzer, target AnalysisTarget) { defer limit.Release(1) defer wg.Done() ret, err := a.Analyze(target) if err != nil && !xerrors.Is(err, aos.AnalyzeOSError) { log.Logger.Debugf("Analysis error: %s", err) return nil, err } result.Merge(ret) }(d, AnalysisTarget{Dir: dir, FilePath: filePath, Content: b}) } return result, nil } // drivers: 用于解析tar包中的文件 func (a alpinePkgAnalyzer) Analyze(target analyzer.AnalysisTarget) (*analyzer.AnalysisResult, error) { scanner := bufio.NewScanner(bytes.NewBuffer(target.Content)) var pkg types.Package var version string for scanner.Scan() { line := scanner.Text() // check package if paragraph end if len(line) < 2 { if analyzer.CheckPackage(&pkg) { pkgs = append(pkgs, pkg) } pkg = types.Package{} continue } switch line[:2] { case "P:": pkg.Name = line[2:] case "V:": version = string(line[2:]) if !apkVersion.Valid(version) { log.Printf("Invalid Version Found : OS %s, Package %s, Version %s", "alpine", pkg.Name, version) continue } pkg.Version = version case "o:": origin := line[2:] pkg.SrcName = origin pkg.SrcVersion = version } } // in case of last paragraph if analyzer.CheckPackage(&pkg) { pkgs = append(pkgs, pkg) } parsedPkgs := a.uniquePkgs(pkgs) return &analyzer.AnalysisResult{ PackageInfos: []types.PackageInfo{ { FilePath: target.FilePath, Packages: parsedPkgs, }, }, }, nil }
以上代码的重点在于 Analyze(target analyzer.AnalysisTarget) 方法,在介绍这个方法之前,有两个特殊文件需要稍微介绍下。众所周知,镜像是分层的,并且所有层都是只读的。当容器是以镜像为基础起来的时候,它会将所有镜像层包含的文件组合成为 rootfs 对容器暂时,当我们将容器 commit 成一个新的镜像的时候,容器内对文件修改会以新的layer 的方式覆盖到原有的镜像中。其中有如下两种特殊文件:
.wh..wh..opq: 代表这个文件所在的目录被删除了
.wh.:以这个词缀开头的文件说明这个文件在当前层已经被删除
所以综上所述,所有容器内的文件删除均不是真正的删除。所以我们在 WalkLayerTar 方法中将两个文件记录下来,跳过解析。
1 Analyze(target analyzer.AnalysisTarget)
首先我们调用 bufio.scanner.Scan() 方法, 他会不断扫描文件中的信息,当返回false 的时候代表扫描到文件结尾,如果这时在扫描过程中没有错误,则 scanner 的 Err 字段为 nil
我们通过 scanner.Text() 获取扫描文件的每一行,截取每一行的前两个字符,得出 apk package 的 package name & package version。
六 读取镜像中的java 应用信息
下面我们实际来看下如何读取java 应用中的依赖信息,包括 应用依赖 & jar包依赖, 首先我们使用上面的方式读取某一层的文件信息。
如果发现 文件是jar包
初始化 zip reader, 开始读取 jar 包内容
开始通过 jar包名称进行解析 artifact的名称和版本, 例如: spring-core-5.3.4-SNAPSHOT.jar => sprint-core, 5.3.4-SNAPSHOT
从 zip reader 读取被压缩的文件
判断文件类型
调用parseArtifact进行递归解析
将返回的innerLibs放到 libs对象中
从 MANIFEST.MF 文件中解析出manifest返回
从 properties 文件中解析 groupid, artifactid, version 并返回
将上述信息放到 libs 对象中
如果是 pom.properties
如果是 MANIFEST.MF
如果是 jar/war/ear 等文件
如果 找不到 artifactid or groupid
根据jar sha256查询对应的包信息
找到直接返回
返回解析出来的libs
func parseArtifact(c conf, fileName string, r io.ReadCloser) ([]types.Library, error) { defer r.Close() b, err := ioutil.ReadAll(r) if err != nil { return nil, xerrors.Errorf("unable to read the jar file: %w", err) } zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) if err != nil { return nil, xerrors.Errorf("zip error: %w", err) } fileName = filepath.Base(fileName) fileProps := parseFileName(fileName) var libs []types.Library var m manifest var foundPomProps bool for _, fileInJar := range zr.File { switch { case filepath.Base(fileInJar.Name) == "pom.properties": props, err := parsePomProperties(fileInJar) if err != nil { return nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err) } libs = append(libs, props.library()) if fileProps.artifactID == props.artifactID && fileProps.version == props.version { foundPomProps = true } case filepath.Base(fileInJar.Name) == "MANIFEST.MF": m, err = parseManifest(fileInJar) if err != nil { return nil, xerrors.Errorf("failed to parse MANIFEST.MF: %w", err) } case isArtifact(fileInJar.Name): fr, err := fileInJar.Open() if err != nil { return nil, xerrors.Errorf("unable to open %s: %w", fileInJar.Name, err) } // 递归解析 jar/war/ear innerLibs, err := parseArtifact(c, fileInJar.Name, fr) if err != nil { return nil, xerrors.Errorf("failed to parse %s: %w", fileInJar.Name, err) } libs = append(libs, innerLibs...) } } // 如果找到了 pom.properties 文件,则直接返回libs对象 if foundPomProps { return libs, nil } // 如果没有找到 pom.properties 文件,则解析MANIFEST.MF 文件 manifestProps := m.properties() if manifestProps.valid() { // 这里即使找到了 artifactid or groupid 也有可能是非法的。这里会访问 maven等仓库确认 jar包是否真正存在 if ok, _ := exists(c, manifestProps); ok { return append(libs, manifestProps.library()), nil } } p, err := searchBySHA1(c, b) if err == nil { return append(libs, p.library()), nil } else if !xerrors.Is(err, ArtifactNotFoundErr) { return nil, xerrors.Errorf("failed to search by SHA1: %w", err) } return libs, nil }
以上我们便完成了从容器镜像中读取信息的功能。
参考:
https://github.com/google/go-containerregistry
https://github.com/aquasecurity/fanal
项目地址: https://github.com/google/go-containerregistry
发表评论 取消回复