Skip to content

Commit 629500d

Browse files
Merge pull request google#1255 from JamesFoxxx:extractor_docker-compose-container-images
PiperOrigin-RevId: 810370636
2 parents 5c789d8 + 6476855 commit 629500d

12 files changed

Lines changed: 510 additions & 10 deletions

File tree

docs/supported_inventory_types.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,12 @@ See the docs on [how to add a new Extractor](/docs/new_extractor.md).
134134

135135
### Container inventory
136136

137-
| Type | Extractor Plugin |
138-
|-----------------------------|------------------------------------------------------------------------------------|
139-
| Containerd container images | `containers/containerd-runtime` (standalone), `containers/containerd` (filesystem) |
140-
| Docker container images | `containers/docker` (standalone) |
141-
| Podman container images | `containers/podman` (filesystem) |
137+
| Type | Extractor Plugin |
138+
|---------------------------------|------------------------------------------------------------------------------------|
139+
| Containerd container images | `containers/containerd-runtime` (standalone), `containers/containerd` (filesystem) |
140+
| Docker container images | `containers/docker` (standalone) |
141+
| Docker Compose container images | `containers/dockercomposeimage` (filesystem) |
142+
| Podman container images | `containers/podman` (filesystem) |
142143

143144
### SBOM files
144145

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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 dockercomposeimage extracts image URLs from Docker Compose files.
16+
package dockercomposeimage
17+
18+
import (
19+
"context"
20+
"errors"
21+
"fmt"
22+
"io"
23+
"os"
24+
"path/filepath"
25+
"sort"
26+
"strings"
27+
28+
"github.com/compose-spec/compose-go/v2/dotenv"
29+
"github.com/compose-spec/compose-go/v2/interpolation"
30+
"github.com/compose-spec/compose-go/v2/loader"
31+
"github.com/compose-spec/compose-go/v2/template"
32+
"github.com/compose-spec/compose-go/v2/tree"
33+
"github.com/compose-spec/compose-go/v2/types"
34+
"github.com/google/osv-scalibr/extractor"
35+
"github.com/google/osv-scalibr/extractor/filesystem"
36+
"github.com/google/osv-scalibr/extractor/filesystem/internal/units"
37+
"github.com/google/osv-scalibr/inventory"
38+
"github.com/google/osv-scalibr/log"
39+
"github.com/google/osv-scalibr/plugin"
40+
"github.com/google/osv-scalibr/purl"
41+
"github.com/google/osv-scalibr/stats"
42+
"gopkg.in/yaml.v3"
43+
)
44+
45+
const (
46+
// Name is the unique name of this extractor.
47+
Name = "containers/dockercomposeimage"
48+
49+
// DefaultMaxFileSizeBytes is the default maximum file size the extractor will
50+
// attempt to extract. If a file is encountered that is larger than this
51+
// limit, the file is ignored by `FileRequired`.
52+
DefaultMaxFileSizeBytes = 1 * units.MiB
53+
)
54+
55+
// Config is the configuration for the Extractor.
56+
type Config struct {
57+
// Stats is a stats collector for reporting metrics.
58+
Stats stats.Collector
59+
// MaxFileSizeBytes is the maximum file size this extractor will unmarshal. If
60+
// `FileRequired` gets a bigger file, it will return false.
61+
MaxFileSizeBytes int64
62+
}
63+
64+
// DefaultConfig returns the default configuration for the extractor.
65+
func DefaultConfig() Config {
66+
return Config{
67+
MaxFileSizeBytes: DefaultMaxFileSizeBytes,
68+
}
69+
}
70+
71+
// Extractor extracts image URLs from Docker Compose files.
72+
type Extractor struct {
73+
stats stats.Collector
74+
maxFileSizeBytes int64
75+
}
76+
77+
// New returns a Docker Compose image extractor.
78+
//
79+
// For most use cases, initialize with:
80+
// ```
81+
// e := New(DefaultConfig())
82+
// ```
83+
func New(cfg Config) *Extractor {
84+
return &Extractor{
85+
stats: cfg.Stats,
86+
maxFileSizeBytes: cfg.MaxFileSizeBytes,
87+
}
88+
}
89+
90+
// NewDefault returns an extractor with the default config settings.
91+
func NewDefault() filesystem.Extractor { return New(DefaultConfig()) }
92+
93+
// Name of the extractor.
94+
func (e Extractor) Name() string { return Name }
95+
96+
// Version of the extractor.
97+
func (e Extractor) Version() int { return 0 }
98+
99+
// Requirements of the extractor.
100+
func (e Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} }
101+
102+
// FileRequired returns true if the specified file could be a Docker Compose file.
103+
func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
104+
path := api.Path()
105+
// Skip directories and oversized files
106+
fi, err := os.Stat(path)
107+
if err != nil || fi.IsDir() {
108+
return false
109+
}
110+
if e.maxFileSizeBytes > 0 && fi.Size() > e.maxFileSizeBytes {
111+
return false
112+
}
113+
filename := filepath.Base(path)
114+
if filepath.Ext(filename) != ".yml" && filepath.Ext(filename) != ".yaml" {
115+
return false
116+
}
117+
return strings.HasPrefix(filename, "compose") ||
118+
strings.HasPrefix(filename, "docker-compose")
119+
}
120+
121+
// Extract extracts image URLs from a Docker Compose file.
122+
func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
123+
if input.Info == nil {
124+
return inventory.Inventory{}, errors.New("input.Info is nil")
125+
}
126+
127+
data, err := io.ReadAll(input.Reader)
128+
if err != nil {
129+
return inventory.Inventory{}, err
130+
}
131+
132+
// Check for a top-level "services" field.
133+
var content map[string]any
134+
if err := yaml.Unmarshal(data, &content); err != nil {
135+
// Not a valid yaml file, not an error.
136+
return inventory.Inventory{}, err
137+
}
138+
if _, ok := content["services"]; !ok {
139+
// Not a compose file, not an error.
140+
return inventory.Inventory{}, nil
141+
}
142+
143+
images, err := uniqueImagesFromReader(ctx, input)
144+
if err != nil {
145+
log.Warnf("Parsing docker-compose file %q failed: %v", input.Path, err)
146+
return inventory.Inventory{}, nil
147+
}
148+
var pkgs []*extractor.Package
149+
for _, image := range images {
150+
name, version := parseName(image)
151+
pkgs = append(pkgs, &extractor.Package{
152+
Locations: []string{input.Path},
153+
Name: name,
154+
Version: version,
155+
PURLType: purl.TypeDocker,
156+
})
157+
}
158+
159+
return inventory.Inventory{Packages: pkgs}, nil
160+
}
161+
162+
// uniqueImagesFromReader extracts unique image names from a Docker Compose file.
163+
// It handles environment variable interpolation and returns a sorted list of unique images.
164+
func uniqueImagesFromReader(ctx context.Context, input *filesystem.ScanInput) ([]string, error) {
165+
absPath, err := input.GetRealPath()
166+
if err != nil {
167+
return nil, fmt.Errorf("GetRealPath(%v): %w", input, err)
168+
}
169+
if input.Root == "" {
170+
// The file got copied to a temporary dir, remove it at the end.
171+
defer func() {
172+
dir := filepath.Dir(absPath)
173+
if err := os.RemoveAll(dir); err != nil {
174+
log.Errorf("os.RemoveAll(%q): %v", dir, err)
175+
}
176+
}()
177+
}
178+
179+
// Load environment variables from a sibling .env file if it exists
180+
workingDir := filepath.Dir(input.Path)
181+
envPath := filepath.ToSlash(filepath.Join(workingDir, ".env"))
182+
environment := types.Mapping{}
183+
if f, err := input.FS.Open(envPath); err == nil {
184+
defer f.Close()
185+
if envVars, err := dotenv.Parse(f); err != nil {
186+
log.Warnf("dotenv.Parse(%q): %v", envPath, err)
187+
} else {
188+
for k, v := range envVars {
189+
environment[k] = v
190+
}
191+
}
192+
} else if !errors.Is(err, os.ErrNotExist) {
193+
log.Warnf("input.FS.Open(%q): %v", envPath, err)
194+
}
195+
configFiles := []types.ConfigFile{
196+
{Filename: absPath},
197+
}
198+
details := types.ConfigDetails{
199+
WorkingDir: workingDir,
200+
ConfigFiles: configFiles,
201+
Environment: environment,
202+
}
203+
customOpts := loader.Options{
204+
Interpolate: &interpolation.Options{
205+
Substitute: substitute,
206+
LookupValue: details.LookupEnv,
207+
TypeCastMapping: make(map[tree.Path]interpolation.Cast),
208+
},
209+
ResolvePaths: true,
210+
}
211+
project, err := loader.LoadWithContext(
212+
ctx,
213+
details,
214+
func(opts *loader.Options) {
215+
*opts = customOpts
216+
})
217+
if err != nil {
218+
return nil, err
219+
}
220+
221+
uniq := map[string]struct{}{}
222+
// We Skip services with an empty image version.
223+
// An empty image version is not a valid image reference.
224+
// This happened because some environment variables are not resolved
225+
for _, s := range project.Services {
226+
if s.Image != "" && !strings.Contains(s.Image, "<IMPERFECT_ENV_VAR_RESOLVING>") {
227+
uniq[s.Image] = struct{}{}
228+
}
229+
}
230+
231+
out := make([]string, 0, len(uniq))
232+
for img := range uniq {
233+
out = append(out, img)
234+
}
235+
sort.Strings(out)
236+
return out, nil
237+
}
238+
239+
// parseName extracts the name and version from an image reference.
240+
// It handles both digest format (name@digest) and tag format (name:tag).
241+
// If no version is specified, it returns "latest" as the default version.
242+
func parseName(name string) (string, string) {
243+
if strings.Contains(name, "@") {
244+
parts := strings.SplitN(name, "@", 2)
245+
return parts[0], parts[1]
246+
}
247+
248+
if strings.Contains(name, ":") {
249+
parts := strings.SplitN(name, ":", 2)
250+
return parts[0], parts[1]
251+
}
252+
253+
return name, "latest"
254+
}
255+
256+
// substitute replaces environment variables in template strings with their values.
257+
// For missing variables, it inserts a placeholder "<IMPERFECT_ENV_VAR_RESOLVING>" to indicate
258+
// that the substitution was incomplete, allowing processing to continue.
259+
func substitute(inTemplate string, mapping template.Mapping) (string, error) {
260+
options := []template.Option{
261+
template.WithPattern(template.DefaultPattern),
262+
template.WithReplacementFunction(
263+
func(substring string, mapping template.Mapping, cfg *template.Config) (string, error) {
264+
value, _, err := template.DefaultReplacementAppliedFunc(substring, mapping, cfg)
265+
if err != nil {
266+
return "", err
267+
}
268+
if value == "" {
269+
// Use placeholder for unresolved variables
270+
value = "<IMPERFECT_ENV_VAR_RESOLVING>"
271+
}
272+
return value, nil
273+
}),
274+
}
275+
276+
return template.SubstituteWithOptions(inTemplate, mapping, options...)
277+
}

0 commit comments

Comments
 (0)