github.com/google/osv-scalibr@v0.4.1/clients/datasource/npm_registry.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 "context" 19 "errors" 20 "fmt" 21 "io" 22 "maps" 23 "net/http" 24 "slices" 25 "sync" 26 "time" 27 28 "github.com/tidwall/gjson" 29 ) 30 31 // NPMRegistryAPIClient defines a client to fetch metadata from a NPM registry. 32 type NPMRegistryAPIClient struct { 33 // Registries from the npmrc config 34 // This should only be written to when the client is first being created. 35 // Other functions should not modify it & it is not covered by the mutex. 36 registries NPMRegistryConfig 37 38 // cache fields 39 mu sync.Mutex 40 cacheTimestamp *time.Time // If set, this means we loaded from a cache 41 details *RequestCache[string, npmRegistryPackageDetails] 42 } 43 44 // NewNPMRegistryAPIClient returns a new NPMRegistryAPIClient. 45 // projectDir is the directory (on disk) to read the project-level .npmrc config file from (for registries). 46 func NewNPMRegistryAPIClient(projectDir string) (*NPMRegistryAPIClient, error) { 47 registryConfig, err := LoadNPMRegistryConfig(projectDir) 48 if err != nil { 49 return nil, err 50 } 51 return &NPMRegistryAPIClient{ 52 registries: registryConfig, 53 details: NewRequestCache[string, npmRegistryPackageDetails](), 54 }, nil 55 } 56 57 // Versions returns all the known versions and tags of a given npm package 58 func (c *NPMRegistryAPIClient) Versions(ctx context.Context, pkg string) (NPMRegistryVersions, error) { 59 pkgDetails, err := c.getPackageDetails(ctx, pkg) 60 if err != nil { 61 return NPMRegistryVersions{}, err 62 } 63 64 return NPMRegistryVersions{ 65 Versions: slices.AppendSeq(make([]string, 0, len(pkgDetails.Versions)), maps.Keys(pkgDetails.Versions)), 66 Tags: pkgDetails.Tags, 67 }, nil 68 } 69 70 // Dependencies returns all the defined dependencies of the given version of an npm package 71 func (c *NPMRegistryAPIClient) Dependencies(ctx context.Context, pkg, version string) (NPMRegistryDependencies, error) { 72 pkgDetails, err := c.getPackageDetails(ctx, pkg) 73 if err != nil { 74 return NPMRegistryDependencies{}, err 75 } 76 77 if deps, ok := pkgDetails.Versions[version]; ok { 78 return deps, nil 79 } 80 81 return NPMRegistryDependencies{}, fmt.Errorf("no version %s for package %s", version, pkg) 82 } 83 84 // FullJSON returns the entire npm registry JSON data for a given package version 85 func (c *NPMRegistryAPIClient) FullJSON(ctx context.Context, pkg, version string) (gjson.Result, error) { 86 return c.get(ctx, pkg, version) 87 } 88 89 func (c *NPMRegistryAPIClient) get(ctx context.Context, urlComponents ...string) (gjson.Result, error) { 90 resp, err := c.registries.MakeRequest(ctx, http.DefaultClient, urlComponents...) 91 if err != nil { 92 return gjson.Result{}, err 93 } 94 95 defer resp.Body.Close() 96 if resp.StatusCode != http.StatusOK { 97 return gjson.Result{}, errors.New(resp.Status) 98 } 99 100 body, err := io.ReadAll(resp.Body) 101 if err != nil { 102 return gjson.Result{}, err 103 } 104 105 res := gjson.ParseBytes(body) 106 107 return res, nil 108 } 109 110 func (c *NPMRegistryAPIClient) getPackageDetails(ctx context.Context, pkg string) (npmRegistryPackageDetails, error) { 111 return c.details.Get(pkg, func() (npmRegistryPackageDetails, error) { 112 jsonData, err := c.get(ctx, pkg) 113 if err != nil { 114 return npmRegistryPackageDetails{}, err 115 } 116 117 versions := make(map[string]NPMRegistryDependencies) 118 for v, data := range jsonData.Get("versions").Map() { 119 versions[v] = NPMRegistryDependencies{ 120 Dependencies: jsonToStringMap(data.Get("dependencies")), 121 DevDependencies: jsonToStringMap(data.Get("devDependencies")), 122 PeerDependencies: jsonToStringMap(data.Get("peerDependencies")), 123 OptionalDependencies: jsonToStringMap(data.Get("optionalDependencies")), 124 BundleDependencies: jsonToStringSlice(data.Get("bundleDependencies")), 125 } 126 } 127 128 return npmRegistryPackageDetails{ 129 Versions: versions, 130 Tags: jsonToStringMap(jsonData.Get("dist-tags")), 131 }, nil 132 }) 133 } 134 135 func jsonToStringSlice(v gjson.Result) []string { 136 arr := v.Array() 137 if len(arr) == 0 { 138 return nil 139 } 140 strs := make([]string, len(arr)) 141 for i, s := range arr { 142 strs[i] = s.String() 143 } 144 145 return strs 146 } 147 148 func jsonToStringMap(v gjson.Result) map[string]string { 149 mp := v.Map() 150 if len(mp) == 0 { 151 return nil 152 } 153 strs := make(map[string]string) 154 for k, s := range mp { 155 strs[k] = s.String() 156 } 157 158 return strs 159 } 160 161 type npmRegistryPackageDetails struct { 162 // Only cache the info needed for the DependencyClient 163 Versions map[string]NPMRegistryDependencies 164 Tags map[string]string 165 } 166 167 // NPMRegistryVersions holds the versions and tags of a package, from the npm API. 168 type NPMRegistryVersions struct { 169 Versions []string 170 Tags map[string]string 171 } 172 173 // NPMRegistryDependencies holds the dependencies of a package version, from the npm API. 174 type NPMRegistryDependencies struct { 175 Dependencies map[string]string 176 DevDependencies map[string]string 177 PeerDependencies map[string]string 178 OptionalDependencies map[string]string 179 BundleDependencies []string 180 }