github.com/google/osv-scalibr@v0.4.1/extractor/filesystem/misc/chrome/extensions/extensions.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 extensions extracts chrome extensions.
    16  package extensions
    17  
    18  import (
    19  	"context"
    20  	"encoding/json"
    21  	"errors"
    22  	"fmt"
    23  	"path/filepath"
    24  	"regexp"
    25  	"runtime"
    26  	"strings"
    27  
    28  	"github.com/google/osv-scalibr/extractor"
    29  	"github.com/google/osv-scalibr/extractor/filesystem"
    30  	"github.com/google/osv-scalibr/inventory"
    31  	"github.com/google/osv-scalibr/plugin"
    32  	"github.com/google/osv-scalibr/purl"
    33  )
    34  
    35  // Name is the name for the Chrome extensions extractor
    36  const Name = "chrome/extensions"
    37  
    38  var (
    39  	windowsChromeExtensionsPattern   = regexp.MustCompile(`(?m)\/Google\/Chrome(?: Beta| SxS| for Testing|)\/User Data\/Default\/Extensions\/[a-p]{32}\/[^\/]+\/manifest\.json$`)
    40  	windowsChromiumExtensionsPattern = regexp.MustCompile(`(?m)\/Chromium\/User Data\/Default\/Extensions\/[a-p]{32}\/[^\/]+\/manifest\.json$`)
    41  
    42  	macosChromeExtensionsPattern   = regexp.MustCompile(`(?m)\/Google\/Chrome(?: Beta| SxS| for Testing| Canary|)\/Default\/Extensions\/[a-p]{32}\/[^\/]+\/manifest\.json$`)
    43  	macosChromiumExtensionsPattern = regexp.MustCompile(`(?m)\/Chromium\/Default\/Extensions\/[a-p]{32}\/[^\/]+\/manifest\.json$`)
    44  
    45  	linuxChromeExtensionsPattern   = regexp.MustCompile(`(?m)\/google-chrome(?:-beta|-unstable|-for-testing|)\/Default\/Extensions\/[a-p]{32}\/[^\/]+\/manifest\.json$`)
    46  	linuxChromiumExtensionsPattern = regexp.MustCompile(`(?m)\/chromium\/Default\/Extensions\/[a-p]{32}\/[^\/]+\/manifest\.json$`)
    47  )
    48  
    49  type manifest struct {
    50  	Author struct {
    51  		Email string `json:"email"`
    52  	} `json:"author"`
    53  	DefaultLocale        string   `json:"default_locale"`
    54  	Description          string   `json:"description"`
    55  	HostPermissions      []string `json:"host_permissions"`
    56  	ManifestVersion      int      `json:"manifest_version"`
    57  	MinimumChromeVersion string   `json:"minimum_chrome_version"`
    58  	Name                 string   `json:"name"`
    59  	Permissions          []string `json:"permissions"`
    60  	UpdateURL            string   `json:"update_url"`
    61  	Version              string   `json:"version"`
    62  }
    63  
    64  func (m *manifest) validate() error {
    65  	if m.Name == "" {
    66  		return errors.New("field 'Name' must be specified")
    67  	}
    68  	if m.Version == "" {
    69  		return errors.New("field 'Version' must be specified")
    70  	}
    71  	return nil
    72  }
    73  
    74  type message struct {
    75  	Description string `json:"description"`
    76  	Message     string `json:"message"`
    77  }
    78  
    79  // Extractor extracts chrome extensions
    80  type Extractor struct{}
    81  
    82  // New returns an chrome extractor.
    83  func New() filesystem.Extractor {
    84  	return &Extractor{}
    85  }
    86  
    87  // Name of the extractor.
    88  func (e Extractor) Name() string { return Name }
    89  
    90  // Version of the extractor.
    91  func (e Extractor) Version() int { return 0 }
    92  
    93  // Requirements of the extractor.
    94  func (e Extractor) Requirements() *plugin.Capabilities {
    95  	return &plugin.Capabilities{
    96  		RunningSystem: true,
    97  	}
    98  }
    99  
   100  // FileRequired returns true if the file is chrome manifest extension
   101  func (e Extractor) FileRequired(api filesystem.FileAPI) bool {
   102  	path := api.Path()
   103  	path = filepath.ToSlash(path)
   104  
   105  	// pre-check to improve performances
   106  	if !strings.HasSuffix(path, "manifest.json") {
   107  		return false
   108  	}
   109  
   110  	switch runtime.GOOS {
   111  	case "windows":
   112  		return windowsChromeExtensionsPattern.MatchString(path) || windowsChromiumExtensionsPattern.MatchString(path)
   113  	case "linux":
   114  		return linuxChromeExtensionsPattern.MatchString(path) || linuxChromiumExtensionsPattern.MatchString(path)
   115  	case "darwin":
   116  		return macosChromeExtensionsPattern.MatchString(path) || macosChromiumExtensionsPattern.MatchString(path)
   117  	default:
   118  		return false
   119  	}
   120  }
   121  
   122  // Extract extracts chrome extensions
   123  func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) (inventory.Inventory, error) {
   124  	var m manifest
   125  	if err := json.NewDecoder(input.Reader).Decode(&m); err != nil {
   126  		return inventory.Inventory{}, fmt.Errorf("could not extract manifest: %w", err)
   127  	}
   128  	if err := m.validate(); err != nil {
   129  		return inventory.Inventory{}, fmt.Errorf("bad format in manifest: %w", err)
   130  	}
   131  
   132  	id, err := extractExtensionsIDFromPath(input)
   133  	if err != nil {
   134  		return inventory.Inventory{}, fmt.Errorf("could not extract extension id: %w", err)
   135  	}
   136  
   137  	// if default locale is specified some fields of the manifest may be
   138  	// written inside the ./_locales/LOCALE_CODE/messages.json file
   139  	if m.DefaultLocale != "" {
   140  		if err := extractLocaleInfo(&m, input); err != nil {
   141  			return inventory.Inventory{}, fmt.Errorf("could not extract locale info: %w", err)
   142  		}
   143  	}
   144  
   145  	return inventory.Inventory{Packages: []*extractor.Package{
   146  		{
   147  			Name:     id,
   148  			Version:  m.Version,
   149  			PURLType: purl.TypeGeneric,
   150  			Metadata: &Metadata{
   151  				AuthorEmail:          m.Author.Email,
   152  				Description:          m.Description,
   153  				HostPermissions:      m.HostPermissions,
   154  				ManifestVersion:      m.ManifestVersion,
   155  				MinimumChromeVersion: m.MinimumChromeVersion,
   156  				Name:                 m.Name,
   157  				Permissions:          m.Permissions,
   158  				UpdateURL:            m.UpdateURL,
   159  			},
   160  		},
   161  	}}, nil
   162  }
   163  
   164  // extractExtensionsIDFromPath extracts the extensions id from the path
   165  //
   166  // expected path is:
   167  //
   168  //	/extensionID/version/manifest.json
   169  func extractExtensionsIDFromPath(input *filesystem.ScanInput) (string, error) {
   170  	parts := strings.Split(filepath.ToSlash(input.Path), "/")
   171  	if len(parts) < 3 {
   172  		return "", errors.New("cold not find id expected path format '/extensionID/version/manifest.json'")
   173  	}
   174  	id := parts[len(parts)-3]
   175  	// no more validation on the id is required since the path has been checked during FileRequired
   176  	return id, nil
   177  }
   178  
   179  // extractLocaleInfo extract locale information from the _locales/LOCALE_CODE/messages.json
   180  // following manifest.json v3 specification
   181  func extractLocaleInfo(m *manifest, input *filesystem.ScanInput) error {
   182  	messagePath := filepath.Join(filepath.Dir(input.Path), "_locales", m.DefaultLocale, "message.json")
   183  	messagePath = filepath.ToSlash(messagePath)
   184  
   185  	f, err := input.FS.Open(messagePath)
   186  	if err != nil {
   187  		return err
   188  	}
   189  
   190  	// using a map to decode since the keys are determined by the values
   191  	// of the manifest.json fields
   192  	//
   193  	// ex:
   194  	//
   195  	// 	manifest.json:
   196  	// 	"name" : "__MSG_43ry328yr932__"
   197  	// 	en/message.json
   198  	// 	"43ry328yr932" : "Extension name"
   199  	var messages map[string]message
   200  	if err := json.NewDecoder(f).Decode(&messages); err != nil {
   201  		return err
   202  	}
   203  
   204  	lowerCase := map[string]message{}
   205  	for k, v := range messages {
   206  		lowerCase[strings.ToLower(k)] = v
   207  	}
   208  
   209  	if v, ok := cutPrefixSuffix(m.Name, "__MSG_", "__"); ok {
   210  		if msg, ok := lowerCase[strings.ToLower(v)]; ok {
   211  			m.Name = msg.Message
   212  		}
   213  	}
   214  
   215  	if v, ok := cutPrefixSuffix(m.Description, "__MSG_", "__"); ok {
   216  		if msg, ok := lowerCase[strings.ToLower(v)]; ok {
   217  			m.Description = msg.Message
   218  		}
   219  	}
   220  
   221  	return nil
   222  }
   223  
   224  // cutPrefixSuffix cuts the specified prefix and suffix if they exist, returns false otherwise
   225  func cutPrefixSuffix(s string, prefix string, suffix string) (string, bool) {
   226  	if !strings.HasPrefix(s, prefix) {
   227  		return "", false
   228  	}
   229  	if !strings.HasSuffix(s, suffix) {
   230  		return "", false
   231  	}
   232  	s = s[len(prefix) : len(s)-len(suffix)]
   233  	return s, true
   234  }