アットランタイム

Clair の仕組みメモ

はじめに

脆弱性スキャナである clair がどのようにして既存の脆弱性をスキャンしているのか気になったので調べてみた。 Clair やそのコア機能のパッケージ claircore は仕組みのドキュメントが非常にまとまっている印象である。

本メモは、Clair の内部アーキテクチャではなく、あるイメージからどのようにインストール済みのパッケージ情報を取得しているかをみていく。 イメージ内のインストール済みのパッケージ情報がわかれば、既存のディストリビューションの脆弱性情報と照らし合わせることができるはず(もちろん既存の脆弱性情報のパースや一般化は大変と思われる)。

Clair の対象

Clair は次のベースイメージに対応している。 Ubuntu や Debian といった特定のディストリビューションとプログラミング言語の脆弱性スキャンに対応している。

Clair supports the extraction of contents and assignment of vulnerabilities from the following official base containers:

    Ubuntu
    Debian
    RHEL
    Suse
    Oracle
    Alpine
    AWS Linux
    VMWare Photon
    Python

レイヤーのスキャン

layerScannerScan から scanLayer が呼びされ、Do がスキャンの実態である。 スキャンは、イメージレイヤーからパッケージのスキャン、ディストリビューションのスキャン、リポジトリーのスキャンの 3 つがある。 ディストリビューションの Scan はシンプルで、そのイメージレイヤー(変数 l)から、そのレイヤーがどのディストリビューションかを判定する。

https://github.com/quay/claircore/blob/v1.1.2/internal/indexer/layerscanner/layerscanner.go#L193

func (r *result) Do(ctx context.Context, s indexer.VersionedScanner, l *claircore.Layer) error {
	var err error
	switch s := s.(type) {
	case indexer.PackageScanner:
		r.pkgs, err = s.Scan(ctx, l)
	case indexer.DistributionScanner:
		r.dists, err = s.Scan(ctx, l)
	case indexer.RepositoryScanner:
		r.repos, err = s.Scan(ctx, l)
	default:
		panic(fmt.Sprintf("programmer error: unknown type %T used as scanner", s))
	}

ディストリビューションのスキャン

例えば、AWS であれば aws ディレクトリにその実装がある。 あるイメージレイヤーから、ディストリビューションに対応したファイル(Amazon Linux であれば etc/os-release)を確認し、そのファイルの値によって対応するディストリビューションかに加えて、ディストリビューションのバージョン(Amazon Linux1 か 2)を判定する。

https://github.com/quay/claircore/blob/v1.1.2/aws/distributionscanner.go#L66

const osReleasePath = `etc/os-release`

func (ds *DistributionScanner) Scan(ctx context.Context, l *claircore.Layer) ([]*claircore.Distribution, error) {
    (snip)
	files, err := l.Files(osReleasePath)
	if err != nil {
		zlog.Debug(ctx).Msg("didn't find an os-release")
		return nil, nil
	}
	for _, buff := range files {
		dist := ds.parse(buff)
		if dist != nil {
			return []*claircore.Distribution{dist}, nil
		}
	}

あるイメージレイヤーから対応したファイルを見つけるという操作は、OCI イメージレイヤーの Tar アーカイブを走査し、ファイル名の一致により行われる。 見つかった場合はそのファイルの内容をバッファに読み込み、ファイル名と読み込んだバッファの map を返す。 このようにすることで、呼び出し側からは、ファイル名で、ファイルの内容(バッファ)を取得・読むことができる。

https://github.com/quay/claircore/blob/v1.1.2/layer.go#L75

func (l *Layer) Files(paths ...string) (map[string]*bytes.Buffer, error) {
	r, err := l.Reader()
	if err != nil {
		return nil, err
	}
	defer r.Close()
	rs := r.(io.ReadSeeker)
    (snip)
	for rs.Seek(0, io.SeekStart); again; rs.Seek(0, io.SeekStart) {
		again = false
		tr := tar.NewReader(rs)
		hdr, err := tr.Next()
		for ; err == nil; hdr, err = tr.Next() {
			name := filepath.Clean(hdr.Name)
			// check if the current header has a path name we are
			// searching for.
			if _, ok := want[name]; !ok {
				continue
			}
			delete(want, name)

			switch hdr.Typeflag {
                (snip)

			case tar.TypeReg:
				b := make([]byte, hdr.Size)
				if n, err := io.ReadFull(tr, b); int64(n) != hdr.Size || err != nil {
					return nil, fmt.Errorf("claircore: unable to read file from archive: read %d bytes (wanted: %d): %w", n, hdr.Size, err)
				}
				f[name] = bytes.NewBuffer(b)

    (snip)

	// If there's nothing in the "f" map, we didn't find anything.
	if len(f) == 0 {
		return nil, ErrNotFound
	}
	return f, nil
var awsRegexes = []awsRegex{
	{
		release: Linux1,
		regexp:  regexp.MustCompile(`Amazon Linux AMI 2018.03`),
	},
	{
		release: Linux2,
		regexp:  regexp.MustCompile(`Amazon Linux 2`),
	},
}

パッケージのスキャン

ある Tar アーカイブのレイヤーを走査し、パッケージのインストール情報が保存されているファイル名を取得する。 パッケージのインストール情報が保存されているファイル名は例えば RPM であれば Pakages である。 Tar ファイルを走査して /var/lib/rpm/Packages ファイルが見つかるとパッケージ情報の DB と認識される(マジックバイトも確認している)。

最終的に重ね合わせたレイヤー(Rootfs)ではなく、Manifest 定義のレイヤー単位で行われるのはポイントかもしれない。

https://github.com/quay/claircore/blob/v1.1.2/rpm/packagescanner.go#L66

// DbNames is a set of files that make up an rpm database.
var dbnames = map[string]struct{}{
	"Packages": {},
}

func (ps *Scanner) Scan(ctx context.Context, layer *claircore.Layer) ([]*claircore.Package, error) {
    (snip)

	possible := make(map[string]int)
	tr := tar.NewReader(rd)
	// Find possible rpm dbs
	// If none found, return
	var h *tar.Header
	for h, err = tr.Next(); err == nil; h, err = tr.Next() {
		n := filepath.Base(h.Name)
		d := filepath.Dir(h.Name)
		if _, ok := dbnames[n]; ok && checkMagic(ctx, tr) {
			possible[d]++
		}
	}

その後、テンポラリディレクトリを作成し、それを root ディレクトリ(ベースディレクトリ)として、レイヤーの Tar アーカイブを復元(解凍)する。 具体的に Tar アーカイブは、タイプ(ファイルやディレクトリ、シンボリックリンク)の情報を持っているのでそれをもとに、ディレクトリの作成やシンボリックの作成のハンドリングする。 この処理自体は、イメージの Pull から OCI Bundle の Rootfs を作成するのと同じである。

	(snip)

	root, err := ioutil.TempDir("", "rpmscanner.")

	(snip)
	for h, err = tr.Next(); err == nil; h, err = tr.Next() {
		if strings.HasPrefix(filepath.Base(h.Name), ".wh.") {
			// Whiteout, skip.
			stats.Whiteout++
			continue
		}

		(snip)

		// Build the path on the filesystem.
		tgt := relPath(root, h.Name)
		// Since tar, as a format, doesn't impose ordering requirements, make
		// sure to create all parent directories of the current entry.
		d := filepath.Dir(tgt)

		(snip)

		// Populate the target file.
		var err error
		switch h.Typeflag {
		case tar.TypeDir:
			m := h.FileInfo().Mode() | dirMode
            (snip)
			err = os.Mkdir(tgt, m)
			// Make sure to preempt the MkdirAll call if the entries were
			// ordered nicely.
			made[d] = struct{}{}
			stats.Dir++
		case tar.TypeReg:
			m := h.FileInfo().Mode() | fileMode
			var f *os.File
			f, err = os.OpenFile(tgt, os.O_CREATE|os.O_WRONLY, m)

			if err != nil {
				break // Handle after the switch.
			}
			_, err = io.Copy(f, tr)
			if err := f.Close(); err != nil {
				zlog.Warn(ctx).Err(err).Msg("error closing new file")
			}
			stats.Reg++
		case tar.TypeSymlink:
			// Normalize the link target into the root.
			ln := relPath(root, h.Linkname)
			err = os.Symlink(ln, tgt)
			stats.Symlink++
		case tar.TypeLink:
			// Normalize the link target into the root.
			ln := relPath(root, h.Linkname)
			_, exists := os.Lstat(ln)
			switch {
			case errors.Is(exists, nil):
				err = os.Link(ln, tgt)
			case errors.Is(exists, os.ErrNotExist):
				// Push onto a queue to fix later. Link(2) is documented to need
				// a valid target, unlike symlink(2), which allows a missing
				// target. Combined with tar's lack of ordering, this seems like
				// the best solution.
				deferLn = append(deferLn, [2]string{ln, tgt})
			default:
				err = exists
			}
			stats.Link++
		default:

あるレイヤーをディレクトリに解凍後、パッケージの DB ファイル(Packages)に対して rpm コマンドを実行してインストールしたパッケージ情報を取得する。

for _, db := range found {
		zlog.Debug(ctx).Str("db", db).Msg("examining database")

		cmd := exec.CommandContext(ctx, "rpm",
			`--root`, root, `--dbpath`, db,
			`--query`, `--all`, `--queryformat`, queryFmt)
		r, err := cmd.StdoutPipe()

リポジトリのスキャン

Java や Python といった言語がリポジトリのスキャンが実装している。 例えば、Python であれば、.egg-info/PKG-INFO.dist-info/METADATA サフィックスのファイルを探して、存在すれば pypi リポジトリと判断する。

https://github.com/quay/claircore/blob/v1.1.2/python/reposcanner.go#L47

var (
    (snip)
	Repository = claircore.Repository{
		Name: "pypi",
		URI:  "https://pypi.org/simple",
	}
)

func (rs *RepoScanner) Scan(ctx context.Context, layer *claircore.Layer) ([]*claircore.Repository, error) {
    for h, err = tr.Next(); err == nil; h, err = tr.Next() {
		n, err := filepath.Rel("/", filepath.Join("/", h.Name))
		if err != nil {
			return nil, err
		}
		switch {
		case h.Typeflag != tar.TypeReg:
			// Should we chase symlinks with the correct name?
			continue
		case strings.HasSuffix(n, `.egg-info/PKG-INFO`):
			zlog.Debug(ctx).Str("file", n).Msg("found egg")
		case strings.HasSuffix(n, `.dist-info/METADATA`):
			zlog.Debug(ctx).Str("file", n).Msg("found wheel")
		default:
			continue
		}

		// Just claim these came from pypi.
		return []*claircore.Repository{&Repository}, nil
	}

また、言語のスキャナは、パッケージのスキャンも実装している。 Python であれば、レイヤーに egg-info/PKG-INFO もしくは .dist-info/METADATA があれば、そのファイルをパースして名前とバージョンを取得する。

https://github.com/quay/claircore/blob/v1.1.2/python/packagescanner.go#L51

func (ps *Scanner) Scan(ctx context.Context, layer *claircore.Layer) ([]*claircore.Package, error) {
    (snip)
    for h, err = tr.Next(); err == nil; h, err = tr.Next() {
		n, err := filepath.Rel("/", filepath.Join("/", h.Name))
		if err != nil {
			return nil, err
		}
		switch {
		case h.Typeflag != tar.TypeReg:
			// Should we chase symlinks with the correct name?
			continue
		case strings.HasSuffix(n, `.egg-info/PKG-INFO`):
			zlog.Debug(ctx).Str("file", n).Msg("found egg")
		case strings.HasSuffix(n, `.dist-info/METADATA`):
			zlog.Debug(ctx).Str("file", n).Msg("found wheel")
(snip)

EcoSystem

ディストリビューション、パッケージ、レポジトリの Scan は、EcoSystem という単位でグルーピングされている。

例えば、ディストリビューションとパッケージは密な関係にある。 Amazon Linux や Oracle は RPM であり、Debian や Ubuntu は dpkg である。 次のようにディストリビューションを内包する形で RPM 側に EcoSystem が実装されている。 dpkg も同様にディストリビューション側ではなく dpkg 側に EcoSystem が実装されている。 RPM の EcoSystem では、リポジトリスキャナの実態はない。

https://github.com/quay/claircore/blob/v1.1.2/rpm/ecosystem.go

// NewEcosystem provides the set of scanners and coalescers for the rpm ecosystem
func NewEcosystem(ctx context.Context) *indexer.Ecosystem {
	return &indexer.Ecosystem{
		PackageScanners: func(ctx context.Context) ([]indexer.PackageScanner, error) {
			return []indexer.PackageScanner{&Scanner{}}, nil
		},
		DistributionScanners: func(ctx context.Context) ([]indexer.DistributionScanner, error) {
			return []indexer.DistributionScanner{
				&aws.DistributionScanner{},
				&oracle.DistributionScanner{},
				&suse.DistributionScanner{},
				&photon.DistributionScanner{},
			}, nil
		},
		RepositoryScanners: func(ctx context.Context) ([]indexer.RepositoryScanner, error) {
			return []indexer.RepositoryScanner{}, nil
		},
		Coalescer: func(ctx context.Context) (indexer.Coalescer, error) {
			return linux.NewCoalescer(), nil
		},
	}
}

言語側の Python も 3 つのスキャナをまとめた EcoSystem が実装されている。 言語側では、ディストリビューションの実態は存在せず、Python のパッケージのリポジトリ(リポジトリ Scan)と、そのパッケージ(パッケージの Scan)である。

パッケージのリポジトリは例えば、pypi であるかの確認であり、パッケージのスキャンは使用しているライブラリのバージョンの確認となる。

https://github.com/quay/claircore/blob/v1.1.2/python/ecosystem.go


var scanners = []indexer.PackageScanner{&Scanner{}}
var reposcanners = []indexer.RepositoryScanner{&RepoScanner{}}

// NewEcosystem provides the set of scanners for the python ecosystem.
func NewEcosystem(ctx context.Context) *indexer.Ecosystem {
	return &indexer.Ecosystem{
		PackageScanners:      func(_ context.Context) ([]indexer.PackageScanner, error) { return scanners, nil },
		DistributionScanners: func(_ context.Context) ([]indexer.DistributionScanner, error) { return nil, nil },
		RepositoryScanners:   func(_ context.Context) ([]indexer.RepositoryScanner, error) { return reposcanners, nil },
		Coalescer:            NewCoalescer,
	}
}

これら EcoSystem を使用することで、透過的に必要なスキャナ(Scan)が実行され、イメージのレイヤーから、ディストリビューション情報や、インストール済みパッケージ情報(リポジトリスキャナ)や、パッケージ情報(パッケージスキャナ)が取得される。 その情報を Postgres あたりにインデックスし、既存の脆弱性情報を比較して脆弱なイメージ(正確にはレイヤーだろうか)を検出すると考えられる。

Appendix

Trivy は

基本的には同じ様子。レイヤーごとに Analyze を実行して Packages ファイルを解析する。 (RPM に関して言えば) Clair のようにあるレイヤーをファイル・ディレクトリとして解凍・展開し、展開したファイルに対して、(コマンドの実行等で)スキャンしているわけではなさそう。 Tar アーカイブを走査して、対象のファイルの内容を io.Reader から内容を読み込み、得た []byte を使用して判定していそう(一度ファイルに書き込んで入るが、レイヤー全てを展開していない)。 また、Clair に比べると Packages ファイルのディレクトリ自体が決め打ちにみえる。

https://github.com/aquasecurity/fanal/blob/main/analyzer/pkg/rpm/rpm.go#L57

var requiredFiles = []string{
	"usr/lib/sysimage/rpm/Packages",
	"var/lib/rpm/Packages",
}
func (a rpmPkgAnalyzer) Analyze(_ context.Context, target analyzer.AnalysisTarget) (*analyzer.AnalysisResult, error) {
	parsedPkgs, installedFiles, err := a.parsePkgInfo(target.Content)
	if err != nil {
		return nil, xerrors.Errorf("failed to parse rpmdb: %w", err)
	}
	(snip)
func (a rpmPkgAnalyzer) parsePkgInfo(packageBytes []byte) ([]types.Package, []string, error) {
	tmpDir, err := ioutil.TempDir("", "rpm")
	defer os.RemoveAll(tmpDir)
	if err != nil {
		return nil, nil, xerrors.Errorf("failed to create a temp dir: %w", err)
	}

	filename := filepath.Join(tmpDir, "Packages")
	err = ioutil.WriteFile(filename, packageBytes, 0700)
	if err != nil {
		return nil, nil, xerrors.Errorf("failed to write a package file: %w", err)
	}

	// rpm-python 4.11.3 rpm-4.11.3-35.el7.src.rpm
	// Extract binary package names because RHSA refers to binary package names.
	db, err := rpmdb.Open(filename)
	if err != nil {
		return nil, nil, xerrors.Errorf("failed to open RPM DB: %w", err)
	}

	// equivalent:
	//   new version: rpm -qa --qf "%{NAME} %{EPOCHNUM} %{VERSION} %{RELEASE} %{SOURCERPM} %{ARCH}\n"
	//   old version: rpm -qa --qf "%{NAME} %{EPOCH} %{VERSION} %{RELEASE} %{SOURCERPM} %{ARCH}\n"
	pkgList, err := db.ListPackages()
	if err != nil {
	(snip)
func (a rpmPkgAnalyzer) Required(filePath string, _ os.FileInfo) bool {
	return utils.StringInSlice(filePath, requiredFiles)
}

上記パッケージごとの解析は AnalyzeFile でラップされている。 Tar アーカイブのレイヤー内のあるファイルに対して、Required でチェックし、対応した RPM や dpkg の Analyze を実行する。

https://github.com/aquasecurity/fanal/blob/main/analyzer/analyzer.go#L188

func (a Analyzer) AnalyzeFile(ctx context.Context, wg *sync.WaitGroup, limit *semaphore.Weighted, result *AnalysisResult,
	dir, filePath string, info os.FileInfo, opener Opener) error {
	if info.IsDir() {
		return nil
	}
	for _, d := range a.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 xerrors.Errorf("unable to open a file (%s): %w", filePath, err)
		}
		(snip)

		go func(a analyzer, target AnalysisTarget) {
			defer limit.Release(1)
			defer wg.Done()

			ret, err := a.Analyze(ctx, target)
			if err != nil && !xerrors.Is(err, aos.AnalyzeOSError) {
				log.Logger.Debugf("Analysis error: %s", err)
				return
			}
			result.Merge(ret)
		}(d, AnalysisTarget{Dir: dir, FilePath: filePath, Content: b})
	}
	return nil
}

AnalyzeFileinspectLayer で呼び出されている。戻り値は BlobInfo である。

https://github.com/aquasecurity/fanal/blob/main/artifact/image/image.go#L202

func (a Artifact) inspectLayer(ctx context.Context, diffID string) (types.BlobInfo, error) {
    (snip)
	opqDirs, whFiles, err := a.walker.Walk(r, func(filePath string, info os.FileInfo, opener analyzer.Opener) error {
		if err = a.analyzer.AnalyzeFile(ctx, &wg, limit, result, "", filePath, info, opener); err != nil {
			return xerrors.Errorf("failed to analyze %s: %w", filePath, err)
		}
		return nil
	})
    (snip)

Analyze は実質 Walk で呼び出されていて、あるレイヤーを読み込んで、それがファイルであれば AnalyzeFile を実行する。 つまり 1 つのレイヤーに対して for-loop でファイルごとに Analyze の実行を試みる。

https://github.com/aquasecurity/fanal/blob/main/walker/tar.go#L27

func (w LayerTar) Walk(layer io.Reader, analyzeFn WalkFunc) ([]string, []string, error) {
	var opqDirs, whFiles, skipDirs []string
	tr := tar.NewReader(layer)
    (snip)
	for {
		hdr, err := tr.Next()
		if err == io.EOF {
			break
        (snip)

		switch hdr.Typeflag {
		case tar.TypeDir:
			if w.shouldSkipDir(filePath) {
				skipDirs = append(skipDirs, filePath)
				continue
			}

        (snip)
		// A symbolic/hard link or regular file will reach here.
		err = analyzeFn(filePath, hdr.FileInfo(), w.fileOnceOpener(tr))
		if err != nil {
			return nil, nil, xerrors.Errorf("failed to analyze file: %w", err)
		}
	}
	return opqDirs, whFiles, nil
}

なお、parsePkgInfo で呼んでいる opener は次で、Tar アーカイブの特定のファイル(RPM の Package ファイル)の内容を全て読み出す。

https://github.com/aquasecurity/fanal/blob/main/walker/walk.go#L79

// fileOnceOpener opens a file once and the content is shared so that some analyzers can use the same data
func (w *walker) fileOnceOpener(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 = io.ReadAll(r)
		})
		if err != nil {
			return nil, xerrors.Errorf("unable to read the file: %w", err)
		}
		return b, nil
	}
}

AnalyzeFile の Merge は append しているだけである。

func (r *AnalysisResult) Merge(new *AnalysisResult) {
(snip)
	if len(new.PackageInfos) > 0 {
		r.PackageInfos = append(r.PackageInfos, new.PackageInfos...)
	}

	if len(new.Applications) > 0 {
		r.Applications = append(r.Applications, new.Applications...)
	}
(snip)

ApplyLayers はレイヤーに対応した複数の BlobInfo(各レイヤーを inspect した情報)を受け取りマージする。 例えば、Packages ファイルが複数のレイヤーに跨っている場合、nested.Nested の引数のパスは /var/lib/rpm/Packages になる。 この際に、Layer 0 から SetByString されていき、最後のレイヤー(例 Layer 4)の内容で nested は上書きされる。(ファイルパスがキーなので)

https://github.com/aquasecurity/fanal/blob/main/applier/docker.go#L85

func ApplyLayers(layers []types.BlobInfo) types.ArtifactDetail {
	sep := "/"
	nestedMap := nested.Nested{}
	var mergedLayer types.ArtifactDetail

	for _, layer := range layers {
		for _, opqDir := range layer.OpaqueDirs {
			opqDir = strings.TrimSuffix(opqDir, sep) //this is necessary so that an empty element is not contribute into the array of the DeleteByString function
			_ = nestedMap.DeleteByString(opqDir, sep)
		}
		for _, whFile := range layer.WhiteoutFiles {
			_ = nestedMap.DeleteByString(whFile, sep)
		}

		if layer.OS != nil {
			mergedLayer.OS = layer.OS
		}

		if layer.Repository != nil {
			mergedLayer.Repository = layer.Repository
		}

		for _, pkgInfo := range layer.PackageInfos {
			key := fmt.Sprintf("%s/type:ospkg", pkgInfo.FilePath)
			nestedMap.SetByString(key, sep, pkgInfo)
		}
		for _, app := range layer.Applications {
			key := fmt.Sprintf("%s/type:%s", app.FilePath, app.Type)
			nestedMap.SetByString(key, sep, app)
		}
(snip)
	}

	_ = nestedMap.Walk(func(keys []string, value interface{}) error {
		switch v := value.(type) {
		case types.PackageInfo:
			mergedLayer.Packages = append(mergedLayer.Packages, v.Packages...)
		case types.Application:
			mergedLayer.Applications = append(mergedLayer.Applications, v)
		case types.Misconfiguration:
			mergedLayer.Misconfigurations = append(mergedLayer.Misconfigurations, v)
		case types.Secret:
			mergedLayer.Secrets = append(mergedLayer.Secrets, v)
		case types.CustomResource:
			mergedLayer.CustomResources = append(mergedLayer.CustomResources, v)
		}
		return nil
	})

	for i, pkg := range mergedLayer.Packages {
		originLayerDigest, originLayerDiffID, buildInfo := lookupOriginLayerForPkg(pkg, layers)
		mergedLayer.Packages[i].Layer = types.Layer{
			Digest: originLayerDigest,
			DiffID: originLayerDiffID,
		}
		mergedLayer.Packages[i].BuildInfo = buildInfo
	}

(snip)

	// Aggregate python/ruby/node.js packages
	aggregate(&mergedLayer)

	return mergedLayer
}

https://github.com/aquasecurity/fanal/blob/main/types/artifact.go#L153

// ArtifactDetail is generated by applying blobs
type ArtifactDetail struct {
	OS                *OS                `json:",omitempty"`
	Repository        *Repository        `json:",omitempty"`
	Packages          []Package          `json:",omitempty"`
	Applications      []Application      `json:",omitempty"`
	Misconfigurations []Misconfiguration `json:",omitempty"`
	Secrets           []Secret           `json:",omitempty"`

	// HistoryPackages are packages extracted from RUN instructions
	HistoryPackages []Package `json:",omitempty"`

	// CustomResources hold analysis results from custom analyzers.
	// It is for extensibility and not used in OSS.
	CustomResources []CustomResource `json:",omitempty"`
}