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  }