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 }