github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/cos/cos.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 cos extracts OS packages from Container Optimized OSes (go/cos). 16 package cos 17 18 import ( 19 "context" 20 "encoding/json" 21 "fmt" 22 "path/filepath" 23 24 "github.com/google/osv-scalibr/extractor" 25 "github.com/google/osv-scalibr/extractor/filesystem" 26 cosmeta "github.com/google/osv-scalibr/extractor/filesystem/os/cos/metadata" 27 "github.com/google/osv-scalibr/extractor/filesystem/os/osrelease" 28 "github.com/google/osv-scalibr/inventory" 29 "github.com/google/osv-scalibr/log" 30 "github.com/google/osv-scalibr/plugin" 31 "github.com/google/osv-scalibr/purl" 32 "github.com/google/osv-scalibr/stats" 33 ) 34 35 const ( 36 // Name is the unique name of this extractor. 37 Name = "os/cos" 38 ) 39 40 // Config is the configuration for the Extractor. 41 type Config struct { 42 // Stats is a stats collector for reporting metrics. 43 Stats stats.Collector 44 // MaxFileSizeBytes is the maximum file size this extractor will unmarshal. If 45 // `FileRequired` gets a bigger file, it will return false, 46 MaxFileSizeBytes int64 47 } 48 49 // DefaultConfig returns the default configuration for the extractor. 50 func DefaultConfig() Config { 51 return Config{ 52 MaxFileSizeBytes: 0, 53 Stats: nil, 54 } 55 } 56 57 // Extractor extracts cos packages from cos database. 58 type Extractor struct { 59 stats stats.Collector 60 maxFileSizeBytes int64 61 } 62 63 // New returns a COS extractor. 64 // 65 // For most use cases, initialize with: 66 // ``` 67 // e := New(DefaultConfig()) 68 // ``` 69 func New(cfg Config) *Extractor { 70 return &Extractor{ 71 stats: cfg.Stats, 72 maxFileSizeBytes: cfg.MaxFileSizeBytes, 73 } 74 } 75 76 // NewDefault returns an extractor with the default config settings. 77 func NewDefault() filesystem.Extractor { return New(DefaultConfig()) } 78 79 // cosPackage represents a COS package found in /etc/cos-package-info.json 80 type cosPackage struct { 81 Category string `json:"category"` 82 Name string `json:"name"` 83 Version string `json:"version"` 84 EbuildVersion string `json:"ebuild_version"` 85 } 86 87 // cosPackageInfo are packages found in /etc/cos-package-info.json. 88 type cosPackageInfo struct { 89 InstalledPackages []cosPackage `json:"installedPackages"` 90 BuildTimePackages []cosPackage `json:"buildTimePackages"` 91 } 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 matches cos package info file pattern. 103 func (e Extractor) FileRequired(api filesystem.FileAPI) bool { 104 path := api.Path() 105 if filepath.ToSlash(path) != "etc/cos-package-info.json" { 106 return false 107 } 108 109 fileinfo, err := api.Stat() 110 if err != nil { 111 return false 112 } 113 if e.maxFileSizeBytes > 0 && fileinfo.Size() > e.maxFileSizeBytes { 114 e.reportFileRequired(path, fileinfo.Size(), stats.FileRequiredResultSizeLimitExceeded) 115 return false 116 } 117 118 e.reportFileRequired(path, fileinfo.Size(), stats.FileRequiredResultOK) 119 return true 120 } 121 122 func (e Extractor) reportFileRequired(path string, fileSizeBytes int64, result stats.FileRequiredResult) { 123 if e.stats == nil { 124 return 125 } 126 e.stats.AfterFileRequired(e.Name(), &stats.FileRequiredStats{ 127 Path: path, 128 Result: result, 129 FileSizeBytes: fileSizeBytes, 130 }) 131 } 132 133 // Extract extracts packages from cos package info files passed through the scan input. 134 func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) { 135 pkgs, err := e.extractFromInput(input) 136 if e.stats != nil { 137 var fileSizeBytes int64 138 if input.Info != nil { 139 fileSizeBytes = input.Info.Size() 140 } 141 e.stats.AfterFileExtracted(e.Name(), &stats.FileExtractedStats{ 142 Path: input.Path, 143 Result: filesystem.ExtractorErrorToFileExtractedResult(err), 144 FileSizeBytes: fileSizeBytes, 145 }) 146 } 147 return inventory.Inventory{Packages: pkgs}, err 148 } 149 150 func (e Extractor) extractFromInput(input *filesystem.ScanInput) ([]*extractor.Package, error) { 151 m, err := osrelease.GetOSRelease(input.FS) 152 if err != nil { 153 log.Errorf("osrelease.ParseOsRelease(): %v", err) 154 } 155 dec := json.NewDecoder(input.Reader) 156 var packages cosPackageInfo 157 if err := dec.Decode(&packages); err != nil { 158 err := fmt.Errorf("failed to json decode: %w", err) 159 log.Debugf(err.Error()) 160 // TODO(b/281023532): We should not mark the overall SCALIBR scan as failed if we can't parse a file. 161 return nil, fmt.Errorf("%w", err) 162 } 163 164 log.Infof("Found %d installed packages", len(packages.InstalledPackages)) 165 log.Infof("Found %d build time packages", len(packages.BuildTimePackages)) 166 167 pkgs := []*extractor.Package{} 168 for _, pkg := range packages.InstalledPackages { 169 pkgs = append(pkgs, &extractor.Package{ 170 Name: pkg.Name, 171 Version: pkg.Version, 172 PURLType: purl.TypeCOS, 173 Metadata: &cosmeta.Metadata{ 174 Name: pkg.Name, 175 Version: pkg.Version, 176 Category: pkg.Category, 177 OSVersion: m["VERSION"], 178 OSVersionID: m["VERSION_ID"], 179 EbuildVersion: pkg.EbuildVersion, 180 }, 181 Locations: []string{input.Path}, 182 }) 183 } 184 185 return pkgs, nil 186 }