|
| 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