github.com/google/osv-scalibr@v0.4.1/clients/datasource/npmrc.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 datasource 16 17 import ( 18 "bytes" 19 "context" 20 "encoding/base64" 21 "errors" 22 "net/http" 23 "net/url" 24 "os" 25 "os/exec" 26 "path/filepath" 27 regex "regexp" 28 "strings" 29 30 "gopkg.in/ini.v1" 31 ) 32 33 // NPMRegistryConfig holds the registry URL configuration from npm config. 34 type NPMRegistryConfig struct { 35 ScopeURLs map[string]string // map of @scope to registry URL 36 Auths NPMRegistryAuths // auth info per npm registry URI 37 } 38 39 // LoadNPMRegistryConfig loads the npmrc file from the project directory and parses it. 40 func LoadNPMRegistryConfig(projectDir string) (NPMRegistryConfig, error) { 41 npmrc, err := loadNpmrc(projectDir) 42 if err != nil { 43 return NPMRegistryConfig{}, err 44 } 45 46 return ParseNPMRegistryInfo(npmrc), nil 47 } 48 49 var npmSupportedAuths = []HTTPAuthMethod{AuthBearer, AuthBasic} 50 51 // ParseNPMRegistryInfo parses the npmrc config into a NPMRegistryConfig. 52 func ParseNPMRegistryInfo(npmrc NpmrcConfig) NPMRegistryConfig { 53 config := NPMRegistryConfig{ 54 ScopeURLs: map[string]string{"": "https://registry.npmjs.org/"}, // set the default registry 55 Auths: make(map[string]*HTTPAuthentication), 56 } 57 58 getOrInitAuth := func(key string) *HTTPAuthentication { 59 if auth, ok := config.Auths[key]; ok { 60 return auth 61 } 62 auth := &HTTPAuthentication{ 63 SupportedMethods: npmSupportedAuths, 64 AlwaysAuth: true, 65 } 66 config.Auths[key] = auth 67 68 return auth 69 } 70 71 for name, value := range npmrc { 72 var part1, part2 string 73 // must split on the last ':' in case e.g. '//localhost:8080/:_auth=xyz' 74 if idx := strings.LastIndex(name, ":"); idx >= 0 { 75 part1, part2 = name[:idx], name[idx+1:] 76 } 77 value := os.ExpandEnv(value) 78 // os.ExpandEnv isn't quire right here - npm config replaces only ${VAR}, not $VAR 79 // and if VAR is unset, it will leave the string as "${VAR}" 80 switch { 81 case name == "registry": // registry=... 82 config.ScopeURLs[""] = value 83 case part2 == "registry": // @scope:registry=... 84 config.ScopeURLs[part1] = value 85 case part2 == "_authToken": // //uri:_authToken=... 86 getOrInitAuth(part1).BearerToken = value 87 case part2 == "_auth": // //uri:_auth=... 88 getOrInitAuth(part1).BasicAuth = value 89 case part2 == "username": // //uri:username=... 90 getOrInitAuth(part1).Username = value 91 case part2 == "_password": // //uri:_password=<base64> 92 password, err := base64.StdEncoding.DecodeString(value) 93 if err != nil { 94 // node's Buffer.from(s, 'base64').toString() is actually much more lenient 95 // e.g. it ignores invalid characters, stops parsing after first '=', never throws an error. 96 // Can't really deal with that here, so just ignore invalid base64. 97 break 98 } 99 getOrInitAuth(part1).Password = string(password) 100 } 101 } 102 103 return config 104 } 105 106 // MakeRequest makes the http request to the corresponding npm registry api (with auth). 107 // urlComponents should be (package) or (package, version) 108 func (r NPMRegistryConfig) MakeRequest(ctx context.Context, httpClient *http.Client, urlComponents ...string) (*http.Response, error) { 109 if len(urlComponents) == 0 { 110 return nil, errors.New("no package specified in npm request") 111 } 112 // find the corresponding registryInfo for the package's scope 113 pkg := urlComponents[0] 114 scope := "" 115 if strings.HasPrefix(pkg, "@") { 116 scope, _, _ = strings.Cut(pkg, "/") 117 } 118 baseURL, ok := r.ScopeURLs[scope] 119 if !ok { 120 // no specific rules for this scope, use the default scope 121 baseURL = r.ScopeURLs[""] 122 } 123 124 for i := range urlComponents { 125 urlComponents[i] = urlPathEscapeLower(urlComponents[i]) 126 } 127 reqURL, err := url.JoinPath(baseURL, urlComponents...) 128 if err != nil { 129 return nil, err 130 } 131 132 return r.Auths.GetAuth(reqURL).Get(ctx, httpClient, reqURL) 133 } 134 135 var urlHexRegex = regex.MustCompile(`%[0-9A-F]{2}`) 136 137 // urlPathEscapeLower is url.PathEscape but with lowercase letters in hex codes (matching npm's behaviour) 138 // e.g. "@reg/pkg" -> "@reg%2fpkg" 139 func urlPathEscapeLower(s string) string { 140 escaped := url.PathEscape(s) 141 142 return urlHexRegex.ReplaceAllStringFunc(escaped, strings.ToLower) 143 } 144 145 // NPMRegistryAuths handles npm registry authentication in a manner similar to npm-registry-fetch 146 // https://github.com/npm/npm-registry-fetch/blob/237d33b45396caa00add61e0549cf09fbf9deb4f/lib/auth.js 147 type NPMRegistryAuths map[string]*HTTPAuthentication 148 149 // GetAuth returns the HTTPAuthentication for the given URI. 150 // This is similar to npm-registry-fetch's getAuth function. 151 func (auths NPMRegistryAuths) GetAuth(uri string) *HTTPAuthentication { 152 parsed, err := url.Parse(uri) 153 if err != nil { 154 return nil 155 } 156 regKey := "//" + parsed.Host + parsed.EscapedPath() 157 for regKey != "//" { 158 if httpAuth, ok := auths[regKey]; ok { 159 // Make sure this httpAuth actually has the necessary fields to construct an auth. 160 // i.e. it's not valid if only Username or only Password is set 161 if httpAuth.BearerToken != "" || 162 httpAuth.BasicAuth != "" || 163 (httpAuth.Username != "" && httpAuth.Password != "") { 164 return httpAuth 165 } 166 } 167 168 // can be either //host/some/path/:_auth or //host/some/path:_auth 169 // walk up by removing EITHER what's after the slash OR the slash itself 170 var found bool 171 if regKey, found = strings.CutSuffix(regKey, "/"); !found { 172 regKey = regKey[:strings.LastIndex(regKey, "/")+1] 173 } 174 } 175 176 return nil 177 } 178 179 // NpmrcConfig is the parsed npmrc config map. 180 type NpmrcConfig map[string]string 181 182 // loadNpmrc finds & parses the 4 npmrc files (builtin, global, user, project) + values set in environment variables 183 // https://docs.npmjs.com/cli/v10/configuring-npm/npmrc 184 // https://docs.npmjs.com/cli/v10/using-npm/config 185 func loadNpmrc(projectDir string) (NpmrcConfig, error) { 186 // project npmrc is always in ./.npmrc 187 projectFile, _ := filepath.Abs(filepath.Join(projectDir, ".npmrc")) 188 builtinFile := builtinNpmrc() 189 envVarOpts, _ := envVarNpmrc() 190 191 opts := ini.LoadOptions{ 192 Loose: true, // ignore missing files 193 KeyValueDelimiters: "=", // default delimiters are "=:", but npmrc uses : in some keys 194 } 195 // Make use of data overwriting to load the correct values 196 fullNpmrc, err := ini.LoadSources(opts, builtinFile, projectFile, envVarOpts) 197 if err != nil { 198 return nil, err 199 } 200 201 // user npmrc is either set as userconfig, or ${HOME}/.npmrc 202 // though userconfig cannot be set in the user or global npmrcs 203 var userFile string 204 switch { 205 case fullNpmrc.Section("").HasKey("userconfig"): 206 userFile = os.ExpandEnv(fullNpmrc.Section("").Key("userconfig").String()) 207 // os.ExpandEnv isn't quire right here - npm config replaces only ${VAR}, not $VAR 208 // and if VAR is unset, it will leave the string as "${VAR}" 209 default: 210 homeDir, err := os.UserHomeDir() 211 if err == nil { // only set userFile if homeDir exists 212 userFile = filepath.Join(homeDir, ".npmrc") 213 } 214 } 215 216 // reload the npmrc files with the user file included 217 fullNpmrc, err = ini.LoadSources(opts, builtinFile, userFile, projectFile, envVarOpts) 218 if err != nil { 219 return nil, err 220 } 221 222 var globalFile string 223 // global npmrc is either set as globalconfig, prefix/etc/npmrc, ${PREFIX}/etc/npmrc 224 // cannot be set within the global npmrc itself 225 switch { 226 case fullNpmrc.Section("").HasKey("globalconfig"): 227 globalFile = os.ExpandEnv(fullNpmrc.Section("").Key("globalconfig").String()) 228 // TODO: Windows 229 case fullNpmrc.Section("").HasKey("prefix"): 230 prefix := os.ExpandEnv(fullNpmrc.Section("").Key("prefix").String()) 231 globalFile, _ = filepath.Abs(filepath.Join(prefix, "etc", "npmrc")) 232 case os.Getenv("PREFIX") != "": 233 globalFile, _ = filepath.Abs(filepath.Join(os.Getenv("PREFIX"), "etc", "npmrc")) 234 } 235 236 // return final joined config, with correct overriding order 237 fullNpmrc, err = ini.LoadSources(opts, builtinFile, globalFile, userFile, projectFile, envVarOpts) 238 if err != nil { 239 return nil, err 240 } 241 242 return fullNpmrc.Section("").KeysHash(), nil 243 } 244 245 func envVarNpmrc() ([]byte, error) { 246 // parse npm config settings that were set in environment variables, 247 // returns a ini.Load()-able byte array of the values 248 249 iniFile := ini.Empty() 250 // npm config environment variables seem to be case-insensitive, interpreted in lowercase 251 // get all the matching environment variables and their values 252 const envPrefix = "npm_config_" 253 for _, env := range os.Environ() { 254 split := strings.SplitN(env, "=", 2) 255 k := strings.ToLower(split[0]) 256 v := split[1] 257 if s, ok := strings.CutPrefix(k, envPrefix); ok { 258 if _, err := iniFile.Section("").NewKey(s, v); err != nil { 259 return nil, err 260 } 261 } 262 } 263 var buf bytes.Buffer 264 _, err := iniFile.WriteTo(&buf) 265 266 return buf.Bytes(), err 267 } 268 269 func builtinNpmrc() string { 270 // builtin is always at /path/to/npm/npmrc 271 npmExec, err := exec.LookPath("npm") 272 if err != nil { 273 return "" 274 } 275 npmExec, err = filepath.EvalSymlinks(npmExec) 276 if err != nil { 277 return "" 278 } 279 npmrc := filepath.Join(filepath.Dir(npmExec), "..", "npmrc") 280 npmrc, err = filepath.Abs(npmrc) 281 if err != nil { 282 return "" 283 } 284 285 return npmrc 286 }