github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/os/macapps/macapps.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 macapps extracts applications data from Info.plist files of OS X devices. 16 package macapps 17 18 import ( 19 "context" 20 "errors" 21 "fmt" 22 "io" 23 "strings" 24 25 "github.com/google/osv-scalibr/extractor" 26 "github.com/google/osv-scalibr/extractor/filesystem" 27 "github.com/google/osv-scalibr/extractor/filesystem/internal/units" 28 "github.com/google/osv-scalibr/inventory" 29 "github.com/google/osv-scalibr/plugin" 30 "github.com/google/osv-scalibr/purl" 31 "github.com/google/osv-scalibr/stats" 32 "github.com/micromdm/plist" 33 ) 34 35 const ( 36 // Name is the unique name of this extractor. 37 Name = "os/macapps" 38 // defaultMaxFileSizeBytes is the default maximum file size to scan. If the file is larger than 39 // this size, it will be skipped. 40 defaultMaxFileSizeBytes = 1 * units.MiB 41 ) 42 43 // Config is the configuration for the Extractor. 44 type Config struct { 45 // Stats is a stats collector for reporting metrics. 46 Stats stats.Collector 47 // MaxFileSizeBytes is the maximum file size this extractor will unmarshal. If 48 // `FileRequired` gets a bigger file, it will return false, 49 MaxFileSizeBytes int64 50 } 51 52 // DefaultConfig returns the default configuration for the MacApp Application extractor. 53 func DefaultConfig() Config { 54 return Config{ 55 Stats: nil, 56 MaxFileSizeBytes: defaultMaxFileSizeBytes, 57 } 58 } 59 60 // Extractor extracts Mac Apps from /Applications Directory. 61 type Extractor struct { 62 stats stats.Collector 63 maxFileSizeBytes int64 64 } 65 66 // New returns a Mac App extractor. 67 // 68 // For most use cases, initialize with: 69 // ``` 70 // e := New(DefaultConfig()) 71 // ``` 72 func New(cfg Config) *Extractor { 73 return &Extractor{ 74 stats: cfg.Stats, 75 maxFileSizeBytes: cfg.MaxFileSizeBytes, 76 } 77 } 78 79 // NewDefault returns an extractor with the default config settings. 80 func NewDefault() filesystem.Extractor { return New(DefaultConfig()) } 81 82 // Config returns the configuration of the extractor. 83 func (e Extractor) Config() Config { 84 return Config{ 85 Stats: e.stats, 86 MaxFileSizeBytes: e.maxFileSizeBytes, 87 } 88 } 89 90 // Name of the extractor. 91 func (e Extractor) Name() string { return Name } 92 93 // Version of the extractor. 94 func (e Extractor) Version() int { return 0 } 95 96 // Requirements of the extractor. 97 func (e Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} } 98 99 // FileRequired returns true if the specified file matches the Info.plist file pattern. 100 func (e Extractor) FileRequired(api filesystem.FileAPI) bool { 101 path := api.Path() 102 // Check for the "/Applications" prefix and ".plist" suffix first. 103 if !strings.HasPrefix(path, "Applications/") || !strings.HasSuffix(path, "/Contents/Info.plist") { 104 return false 105 } 106 107 // Skip sub packages. 108 if strings.Count(path, "/Contents/") != 1 { 109 return false 110 } 111 112 fileinfo, err := api.Stat() 113 if err != nil { 114 return false 115 } 116 if e.maxFileSizeBytes > 0 && fileinfo.Size() > e.maxFileSizeBytes { 117 e.reportFileRequired(path, fileinfo.Size(), stats.FileRequiredResultSizeLimitExceeded) 118 return false 119 } 120 121 e.reportFileRequired(path, fileinfo.Size(), stats.FileRequiredResultOK) 122 return true 123 } 124 125 func (e Extractor) reportFileRequired(path string, fileSizeBytes int64, result stats.FileRequiredResult) { 126 if e.stats == nil { 127 return 128 } 129 e.stats.AfterFileRequired(e.Name(), &stats.FileRequiredStats{ 130 Path: path, 131 Result: result, 132 FileSizeBytes: fileSizeBytes, 133 }) 134 } 135 136 // Extract extracts packages from Info.plist files passed through the scan input. 137 func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) { 138 p, err := e.extractFromInput(input) 139 if e.stats != nil { 140 var fileSizeBytes int64 141 if input.Info != nil { 142 fileSizeBytes = input.Info.Size() 143 } 144 e.stats.AfterFileExtracted(e.Name(), &stats.FileExtractedStats{ 145 Path: input.Path, 146 Result: filesystem.ExtractorErrorToFileExtractedResult(err), 147 FileSizeBytes: fileSizeBytes, 148 }) 149 } 150 if err != nil { 151 return inventory.Inventory{}, fmt.Errorf("macOS Application.extract: %w", err) 152 } 153 if p == nil { 154 return inventory.Inventory{}, nil 155 } 156 return inventory.Inventory{Packages: []*extractor.Package{p}}, nil 157 } 158 159 func (e Extractor) extractFromInput(input *filesystem.ScanInput) (*extractor.Package, error) { 160 // Read the first 8 bytes to check for binary plist header 161 header := make([]byte, 8) 162 _, err := io.ReadFull(input.Reader, header) 163 if err != nil { 164 return nil, fmt.Errorf("error reading plist header: %w", err) 165 } 166 // Type Cast to ReadSeeker 167 rs, ok := input.Reader.(io.ReadSeeker) // Type assertion 168 if _, err := rs.Seek(0, io.SeekStart); err != nil { // Use seeker here 169 return nil, fmt.Errorf("error seeking to beginning of file: %w", err) 170 } 171 var metadata Metadata 172 173 if !ok { 174 return nil, errors.New("input.Reader does not support readseeker") 175 } 176 if string(header) == "bplist00" { 177 // Binary plist 178 decoder := plist.NewBinaryDecoder(rs) 179 err := decoder.Decode(&metadata) 180 if err != nil { 181 return nil, fmt.Errorf("error decoding Binary plist: %w", err) 182 } 183 } else { 184 // XML plist 185 decoder := plist.NewXMLDecoder(input.Reader) 186 err := decoder.Decode(&metadata) 187 if err != nil { 188 return nil, fmt.Errorf("error decoding XML plist: %w", err) 189 } 190 } 191 192 p := &extractor.Package{ 193 Name: metadata.CFBundleName, 194 Version: metadata.CFBundleShortVersionString, 195 PURLType: purl.TypeMacApps, 196 Metadata: &metadata, 197 Locations: []string{input.Path}, 198 } 199 200 return p, nil 201 }