github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/lockfile/npm/packagelockjson.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 npm provides the lockfile parsing and writing for the npm package-lock.json format. 16 package npm 17 18 import ( 19 "encoding/json" 20 "errors" 21 "fmt" 22 "io" 23 "os" 24 "path/filepath" 25 26 "deps.dev/util/resolve" 27 "deps.dev/util/resolve/dep" 28 "github.com/google/osv-scalibr/clients/datasource" 29 scalibrfs "github.com/google/osv-scalibr/fs" 30 "github.com/google/osv-scalibr/guidedremediation/internal/lockfile" 31 "github.com/google/osv-scalibr/guidedremediation/internal/manifest/npm" 32 "github.com/google/osv-scalibr/guidedremediation/result" 33 "github.com/google/osv-scalibr/guidedremediation/strategy" 34 "github.com/google/osv-scalibr/internal/dependencyfile/packagelockjson" 35 "github.com/google/osv-scalibr/log" 36 ) 37 38 type readWriter struct{} 39 40 // GetReadWriter returns a ReadWriter for package-lock.json lockfiles. 41 func GetReadWriter() (lockfile.ReadWriter, error) { 42 return readWriter{}, nil 43 } 44 45 // System returns the ecosystem of this ReadWriter. 46 func (r readWriter) System() resolve.System { 47 return resolve.NPM 48 } 49 50 // SupportedStrategies returns the remediation strategies supported for this lockfile. 51 func (r readWriter) SupportedStrategies() []strategy.Strategy { 52 return []strategy.Strategy{strategy.StrategyInPlace} 53 } 54 55 type dependencyVersionSpec struct { 56 Version string 57 DepType dep.Type 58 } 59 60 type nodeModule struct { 61 NodeID resolve.NodeID 62 Parent *nodeModule 63 Children map[string]*nodeModule // keyed on package name 64 Deps map[string]dependencyVersionSpec 65 ActualName string // set if the node is an alias, the real package name this refers to 66 } 67 68 func (n nodeModule) IsAliased() bool { 69 return n.ActualName != "" 70 } 71 72 // Read parses the dependency graph from the given lockfile. 73 func (r readWriter) Read(path string, fsys scalibrfs.FS) (*resolve.Graph, error) { 74 path = filepath.ToSlash(path) 75 f, err := fsys.Open(path) 76 if err != nil { 77 return nil, err 78 } 79 defer f.Close() 80 81 dec := json.NewDecoder(f) 82 var lockJSON packagelockjson.LockFile 83 if err := dec.Decode(&lockJSON); err != nil { 84 return nil, err 85 } 86 87 // Build the node_modules directory tree in memory & add unconnected nodes into graph 88 var g *resolve.Graph 89 var nodeModuleTree *nodeModule 90 switch { 91 case lockJSON.Packages != nil: 92 g, nodeModuleTree, err = nodesFromPackages(lockJSON) 93 case lockJSON.Dependencies != nil: 94 pkgJSONPath := filepath.ToSlash(filepath.Join(filepath.Dir(path), "package.json")) 95 pkgJSONFile, ferr := fsys.Open(pkgJSONPath) 96 if ferr != nil { 97 return nil, fmt.Errorf("failed to open package.json (required for parsing lockfileVersion 1): %w", err) 98 } 99 defer pkgJSONFile.Close() 100 g, nodeModuleTree, err = nodesFromDependencies(lockJSON, pkgJSONFile) 101 default: 102 return nil, errors.New("no dependencies in package-lock.json") 103 } 104 if err != nil { 105 return nil, fmt.Errorf("error when parsing package-lock.json: %w", err) 106 } 107 108 // Traverse the graph (somewhat inefficiently) to add edges between nodes 109 aliasNodes := make(map[resolve.NodeID]string) 110 todo := []*nodeModule{nodeModuleTree} 111 seen := make(map[*nodeModule]struct{}) 112 seen[nodeModuleTree] = struct{}{} 113 114 for len(todo) > 0 { 115 node := todo[0] 116 todo = todo[1:] 117 if node.IsAliased() { 118 // Note which nodes that have to be renamed because of aliasing 119 // Don't rename them now because we rely on the names for working out edges 120 aliasNodes[node.NodeID] = node.ActualName 121 } 122 123 // Add the directory's children to the queue 124 for _, child := range node.Children { 125 if _, ok := seen[child]; !ok { 126 todo = append(todo, child) 127 seen[child] = struct{}{} 128 } 129 } 130 131 // Add edges to the correct dependency nodes 132 for depName, depSpec := range node.Deps { 133 depNode := findDependencyNode(node, depName) 134 if depNode == -1 { 135 // The dependency is apparently not in the package-lock.json. 136 // Either this is an uninstalled optional dependency (which is fine), 137 // or lockfile is (probably) malformed, and npm would usually error installing this. 138 // But there are some cases (with workspaces) that npm doesn't error, 139 // so just always ignore the error to make it work. 140 if !depSpec.DepType.HasAttr(dep.Opt) { 141 log.Warnf("package-lock.json is missing dependency %s for %s", depName, g.Nodes[node.NodeID].Version.Name) 142 } 143 continue 144 } 145 if err := g.AddEdge(node.NodeID, depNode, depSpec.Version, depSpec.DepType); err != nil { 146 return nil, err 147 } 148 } 149 } 150 151 // Add alias KnownAs attribute and rename them correctly 152 for i, e := range g.Edges { 153 if _, ok := aliasNodes[e.To]; ok { 154 name := g.Nodes[e.To].Version.Name 155 g.Edges[i].Type.AddAttr(dep.KnownAs, name) 156 } 157 } 158 for i := range g.Nodes { 159 if name, ok := aliasNodes[resolve.NodeID(i)]; ok { 160 g.Nodes[i].Version.Name = name 161 } 162 } 163 164 return g, nil 165 } 166 167 // Write writes the lockfile after applying the patches to outputPath. 168 func (r readWriter) Write(path string, fsys scalibrfs.FS, patches []result.Patch, outputPath string) error { 169 // Read the whole package-lock.json into memory so we can use sjson to write in-place. 170 f, err := fsys.Open(path) 171 if err != nil { 172 return err 173 } 174 lockf, err := io.ReadAll(f) 175 f.Close() 176 if err != nil { 177 return err 178 } 179 180 // Map of package name to original version to new version, for easier lookup of patches. 181 patchMap := make(map[string]map[string]string) 182 for _, p := range patches { 183 for _, pu := range p.PackageUpdates { 184 if _, ok := patchMap[pu.Name]; !ok { 185 patchMap[pu.Name] = make(map[string]string) 186 } 187 patchMap[pu.Name][pu.VersionFrom] = pu.VersionTo 188 } 189 } 190 191 // We need access to the npm registry to get information about the new versions. (e.g. hashes) 192 api, err := datasource.NewNPMRegistryAPIClient(filepath.Dir(outputPath)) 193 if err != nil { 194 return fmt.Errorf("failed to connect to npm registry: %w", err) 195 } 196 197 if lockf, err = writeDependencies(lockf, patchMap, api); err != nil { 198 return err 199 } 200 if lockf, err = writePackages(lockf, patchMap, api); err != nil { 201 return err 202 } 203 204 // Write the patched lockfile to the output path. 205 if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { 206 return err 207 } 208 return os.WriteFile(outputPath, lockf, 0644) 209 } 210 211 func findDependencyNode(node *nodeModule, depName string) resolve.NodeID { 212 // Walk up the node_modules to find which node would be used as the requirement 213 for node != nil { 214 if child, ok := node.Children[depName]; ok { 215 return child.NodeID 216 } 217 node = node.Parent 218 } 219 220 return resolve.NodeID(-1) 221 } 222 223 func reVersionAliasedDeps(deps map[string]dependencyVersionSpec) { 224 // for the dependency maps, change versions from "npm:pkg@version" to "version" 225 for k, v := range deps { 226 _, v.Version = npm.SplitNPMAlias(v.Version) 227 deps[k] = v 228 } 229 }