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  }