github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/manifest/npm/packagejson.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 manifest parsing and writing for the npm package.json format. 16 package npm 17 18 import ( 19 "encoding/json" 20 "fmt" 21 "io" 22 "io/fs" 23 "maps" 24 "os" 25 "path/filepath" 26 "slices" 27 "strings" 28 29 "deps.dev/util/resolve" 30 "deps.dev/util/resolve/dep" 31 scalibrfs "github.com/google/osv-scalibr/fs" 32 "github.com/google/osv-scalibr/guidedremediation/internal/manifest" 33 "github.com/google/osv-scalibr/guidedremediation/result" 34 "github.com/google/osv-scalibr/guidedremediation/strategy" 35 "github.com/google/osv-scalibr/log" 36 "github.com/tidwall/gjson" 37 "github.com/tidwall/sjson" 38 ) 39 40 // RequirementKey is a comparable type that uniquely identifies a package dependency in a manifest. 41 type RequirementKey struct { 42 resolve.PackageKey 43 44 KnownAs string 45 } 46 47 var _ map[RequirementKey]any 48 49 // MakeRequirementKey constructs an npm RequirementKey from the given RequirementVersion. 50 func MakeRequirementKey(requirement resolve.RequirementVersion) manifest.RequirementKey { 51 // Npm requirements are the uniquely identified by the key in the dependencies fields (which ends up being the path in node_modules) 52 // Declaring a dependency in multiple places (dependencies, devDependencies, optionalDependencies) only installs it once at one version. 53 // Aliases & non-registry dependencies are keyed on their 'KnownAs' attribute. 54 knownAs, _ := requirement.Type.GetAttr(dep.KnownAs) 55 return RequirementKey{ 56 PackageKey: requirement.PackageKey, 57 KnownAs: knownAs, 58 } 59 } 60 61 type npmManifest struct { 62 filePath string 63 root resolve.Version 64 requirements []resolve.RequirementVersion 65 groups map[manifest.RequirementKey][]string 66 localManifests []*npmManifest 67 } 68 69 // FilePath returns the path to the manifest file. 70 func (m *npmManifest) FilePath() string { 71 return m.filePath 72 } 73 74 // Root returns the Version representing this package. 75 func (m *npmManifest) Root() resolve.Version { 76 return m.root 77 } 78 79 // System returns the ecosystem of this manifest. 80 func (m *npmManifest) System() resolve.System { 81 return resolve.NPM 82 } 83 84 // Requirements returns all direct requirements (including dev). 85 func (m *npmManifest) Requirements() []resolve.RequirementVersion { 86 return m.requirements 87 } 88 89 // Groups returns the dependency groups that the direct requirements belong to. 90 func (m *npmManifest) Groups() map[manifest.RequirementKey][]string { 91 return m.groups 92 } 93 94 // LocalManifests returns Manifests of any local packages. 95 func (m *npmManifest) LocalManifests() []manifest.Manifest { 96 locals := make([]manifest.Manifest, len(m.localManifests)) 97 for i, l := range m.localManifests { 98 locals[i] = l 99 } 100 return locals 101 } 102 103 // EcosystemSpecific returns any ecosystem-specific information for this manifest. 104 func (m *npmManifest) EcosystemSpecific() any { 105 return nil 106 } 107 108 // Clone returns a copy of this manifest that is safe to modify. 109 func (m *npmManifest) Clone() manifest.Manifest { 110 clone := &npmManifest{ 111 filePath: m.filePath, 112 root: m.root, 113 requirements: slices.Clone(m.requirements), 114 groups: maps.Clone(m.groups), 115 } 116 clone.root.AttrSet = m.root.Clone() 117 clone.localManifests = make([]*npmManifest, len(m.localManifests)) 118 for i, local := range m.localManifests { 119 clone.localManifests[i] = local.Clone().(*npmManifest) 120 } 121 122 return clone 123 } 124 125 // PatchRequirement modifies the manifest's requirements to include the new requirement version. 126 func (m *npmManifest) PatchRequirement(req resolve.RequirementVersion) error { 127 reqKey := MakeRequirementKey(req) 128 for i, oldReq := range m.requirements { 129 if MakeRequirementKey(oldReq) == reqKey { 130 m.requirements[i] = req 131 return nil 132 } 133 } 134 135 return fmt.Errorf("package %s not found in manifest", req.Name) 136 } 137 138 type readWriter struct{} 139 140 // GetReadWriter returns a ReadWriter for package.json manifest files. 141 // registry is unused. 142 func GetReadWriter() (manifest.ReadWriter, error) { 143 return readWriter{}, nil 144 } 145 146 // System returns the ecosystem of this ReadWriter. 147 func (r readWriter) System() resolve.System { 148 return resolve.NPM 149 } 150 151 // SupportedStrategies returns the remediation strategies supported for this manifest. 152 func (r readWriter) SupportedStrategies() []strategy.Strategy { 153 return []strategy.Strategy{strategy.StrategyRelax} 154 } 155 156 // PackageJSON is the structure for the contents of a package.json file. 157 type PackageJSON struct { 158 Name string `json:"name"` 159 Version string `json:"version"` 160 Workspaces []string `json:"workspaces"` 161 Dependencies map[string]string `json:"dependencies"` 162 DevDependencies map[string]string `json:"devDependencies"` 163 OptionalDependencies map[string]string `json:"optionalDependencies"` 164 PeerDependencies map[string]string `json:"peerDependencies"` 165 PeerDependenciesMeta map[string]struct { 166 Optional bool `json:"optional,omitempty"` 167 } `json:"peerDependenciesMeta,omitempty"` 168 } 169 170 // Read parses the manifest from the given file. 171 func (r readWriter) Read(path string, fsys scalibrfs.FS) (manifest.Manifest, error) { 172 return parse(path, fsys, true) 173 } 174 175 func parse(path string, fsys scalibrfs.FS, doWorkspaces bool) (*npmManifest, error) { 176 path = filepath.ToSlash(path) 177 f, err := fsys.Open(path) 178 if err != nil { 179 return nil, err 180 } 181 defer f.Close() 182 183 dec := json.NewDecoder(f) 184 var pkgJSON PackageJSON 185 if err := dec.Decode(&pkgJSON); err != nil { 186 return nil, err 187 } 188 189 // Create the root node. 190 manif := &npmManifest{ 191 filePath: path, 192 root: resolve.Version{ 193 VersionKey: resolve.VersionKey{ 194 PackageKey: resolve.PackageKey{ 195 System: resolve.NPM, 196 Name: pkgJSON.Name, 197 }, 198 VersionType: resolve.Concrete, 199 Version: pkgJSON.Version, 200 }, 201 }, 202 groups: make(map[manifest.RequirementKey][]string), 203 } 204 205 workspaceNames := make(map[string]struct{}) 206 if doWorkspaces { 207 // Find all package.json files in the workspaces & parse those too. 208 var workspaces []string 209 for _, pattern := range pkgJSON.Workspaces { 210 p := filepath.ToSlash(filepath.Join(filepath.Dir(path), pattern, "package.json")) 211 match, err := fs.Glob(fsys, p) 212 if err != nil { 213 return nil, err 214 } 215 workspaces = append(workspaces, match...) 216 } 217 218 // workspaces seem to be evaluated in sorted path order 219 slices.Sort(workspaces) 220 for _, path := range workspaces { 221 m, err := parse(path, fsys, false) // workspaces cannot have their own workspaces. 222 if err != nil { 223 return nil, err 224 } 225 manif.localManifests = append(manif.localManifests, m) 226 workspaceNames[m.root.Name] = struct{}{} 227 } 228 } 229 230 isWorkspace := func(req resolve.RequirementVersion) bool { 231 if req.Type.HasAttr(dep.KnownAs) { 232 // "alias": "npm:pkg@*" seems to always take the real 'pkg', 233 // even if there's a workspace with the same name. 234 return false 235 } 236 _, ok := workspaceNames[req.Name] 237 238 return ok 239 } 240 241 workspaceReqVers := make(map[resolve.PackageKey]resolve.RequirementVersion) 242 243 // empirically, the dev version takes precedence over optional, which takes precedence over regular, if they conflict. 244 for pkg, ver := range pkgJSON.Dependencies { 245 req, ok := makeNPMReqVer(pkg, ver) 246 if !ok { 247 log.Warnf("Skipping unsupported requirement: \"%s\": \"%s\"", pkg, ver) 248 continue 249 } 250 if isWorkspace(req) { 251 // workspaces seem to always be evaluated separately 252 workspaceReqVers[req.PackageKey] = req 253 continue 254 } 255 manif.requirements = append(manif.requirements, req) 256 } 257 258 for pkg, ver := range pkgJSON.OptionalDependencies { 259 req, ok := makeNPMReqVer(pkg, ver) 260 if !ok { 261 log.Warnf("Skipping unsupported requirement: \"%s\": \"%s\"", pkg, ver) 262 continue 263 } 264 req.Type.AddAttr(dep.Opt, "") 265 if isWorkspace(req) { 266 // workspaces seem to always be evaluated separately 267 workspaceReqVers[req.PackageKey] = req 268 continue 269 } 270 idx := slices.IndexFunc(manif.requirements, func(imp resolve.RequirementVersion) bool { 271 return imp.PackageKey == req.PackageKey 272 }) 273 if idx != -1 { 274 manif.requirements[idx] = req 275 } else { 276 manif.requirements = append(manif.requirements, req) 277 } 278 manif.groups[MakeRequirementKey(req)] = []string{"optional"} 279 } 280 281 for pkg, ver := range pkgJSON.DevDependencies { 282 req, ok := makeNPMReqVer(pkg, ver) 283 if !ok { 284 log.Warnf("Skipping unsupported requirement: \"%s\": \"%s\"", pkg, ver) 285 continue 286 } 287 if isWorkspace(req) { 288 // workspaces seem to always be evaluated separately 289 workspaceReqVers[req.PackageKey] = req 290 continue 291 } 292 idx := slices.IndexFunc(manif.requirements, func(imp resolve.RequirementVersion) bool { 293 return imp.PackageKey == req.PackageKey 294 }) 295 if idx != -1 { 296 // In newer versions of npm, having a package in both the `dependencies` and `devDependencies` 297 // makes it treated as ONLY a devDependency (using the devDependency version) 298 // npm v6 and below seems to do the opposite and there's no easy way of seeing the npm version... 299 manif.requirements[idx] = req 300 } else { 301 manif.requirements = append(manif.requirements, req) 302 } 303 manif.groups[MakeRequirementKey(req)] = []string{"dev"} 304 } 305 306 resolve.SortDependencies(manif.requirements) 307 308 // resolve workspaces after regular requirements 309 for i, m := range manif.localManifests { 310 imp, ok := workspaceReqVers[m.root.PackageKey] 311 if !ok { // The workspace isn't directly used by the root package, add it as a 'requirement' anyway so it's resolved 312 imp = resolve.RequirementVersion{ 313 Type: dep.NewType(), 314 VersionKey: resolve.VersionKey{ 315 PackageKey: m.root.PackageKey, 316 Version: "*", // use the 'any' specifier so we always match the sub-package version 317 VersionType: resolve.Requirement, 318 }, 319 } 320 } 321 // Add an extra identifier to the workspace package names so name collisions don't overwrite indirect dependencies 322 imp.Name += ":workspace" 323 manif.localManifests[i].root.Name = imp.Name 324 manif.requirements = append(manif.requirements, imp) 325 // replace the workspace's sibling requirements 326 for j, req := range m.requirements { 327 if isWorkspace(req) { 328 manif.localManifests[i].requirements[j].Name = req.Name + ":workspace" 329 reqKey := MakeRequirementKey(req) 330 if g, ok := m.groups[reqKey]; ok { 331 newKey := MakeRequirementKey(manif.localManifests[i].requirements[j]) 332 manif.localManifests[i].groups[newKey] = g 333 delete(manif.localManifests[i].groups, reqKey) 334 } 335 } 336 } 337 } 338 339 return manif, nil 340 } 341 342 func makeNPMReqVer(pkg, ver string) (resolve.RequirementVersion, bool) { 343 typ := dep.NewType() // don't use dep.NewType(dep.Dev) for devDeps to force the resolver to resolve them 344 realPkg, realVer := SplitNPMAlias(ver) 345 if realPkg != "" { 346 // This dependency is aliased, add it as a 347 // dependency on the actual name, with the 348 // KnownAs attribute set to the alias. 349 typ.AddAttr(dep.KnownAs, pkg) 350 pkg = realPkg 351 ver = realVer 352 } 353 if strings.ContainsAny(ver, ":/") { 354 // Skip non-registry dependencies 355 // e.g. `git+https://...`, `file:...`, `github-user/repo` 356 return resolve.RequirementVersion{}, false 357 } 358 359 return resolve.RequirementVersion{ 360 Type: typ, 361 VersionKey: resolve.VersionKey{ 362 PackageKey: resolve.PackageKey{ 363 Name: pkg, 364 System: resolve.NPM, 365 }, 366 Version: ver, 367 VersionType: resolve.Requirement, 368 }, 369 }, true 370 } 371 372 // SplitNPMAlias extracts the real package name and version from an alias-specified version. 373 // 374 // e.g. "npm:pkg@^1.2.3" -> name: "pkg", version: "^1.2.3" 375 // 376 // If the version is not an alias specifier, the name will be empty and the version unchanged. 377 func SplitNPMAlias(v string) (name, version string) { 378 if r, ok := strings.CutPrefix(v, "npm:"); ok { 379 if i := strings.LastIndex(r, "@"); i > 0 { 380 return r[:i], r[i+1:] 381 } 382 383 return r, "" // alias with no version specified 384 } 385 386 return "", v // not an alias 387 } 388 389 // Write applies the patches to the original manifest, writing the resulting manifest file to the file path in the filesystem. 390 func (r readWriter) Write(original manifest.Manifest, fsys scalibrfs.FS, patches []result.Patch, outputPath string) error { 391 // Read the whole package.json into memory so we can use sjson to write in-place. 392 f, err := fsys.Open(original.FilePath()) 393 if err != nil { 394 return err 395 } 396 manif, err := io.ReadAll(f) 397 f.Close() 398 if err != nil { 399 return err 400 } 401 402 for _, patch := range patches { 403 for _, req := range patch.PackageUpdates { 404 name := req.Name 405 origVer := req.VersionFrom 406 newVer := req.VersionTo 407 if knownAs, ok := req.Type.GetAttr(dep.KnownAs); ok { 408 // reconstruct alias versioning 409 origVer = fmt.Sprintf("npm:%s@%s", name, origVer) 410 newVer = fmt.Sprintf("npm:%s@%s", name, newVer) 411 name = knownAs 412 } 413 414 // Don't know what kind of dependency this is, so check them all. 415 // Check them in dev -> optional -> prod because that's the order npm seems to use when they conflict. 416 alreadyMatched := false 417 depStr := "devDependencies." + name 418 if res := gjson.GetBytes(manif, depStr); res.Exists() { 419 ver := res.String() 420 if ver != origVer { 421 return fmt.Errorf("original dependency version does not match patch: %s %q != %q", name, ver, origVer) 422 } 423 manif, err = sjson.SetBytes(manif, depStr, newVer) 424 if err != nil { 425 return err 426 } 427 alreadyMatched = true 428 } 429 430 depStr = "optionalDependencies." + name 431 if res := gjson.GetBytes(manif, depStr); res.Exists() { 432 ver := res.String() 433 if ver != origVer { 434 if !alreadyMatched { 435 return fmt.Errorf("original dependency version does not match patch: %s %q != %q", name, ver, origVer) 436 } 437 // dependency was already matched, so we can ignore it. 438 } else { 439 manif, err = sjson.SetBytes(manif, depStr, newVer) 440 if err != nil { 441 return err 442 } 443 alreadyMatched = true 444 } 445 } 446 447 depStr = "dependencies." + name 448 if res := gjson.GetBytes(manif, depStr); res.Exists() { 449 ver := res.String() 450 if ver != origVer { 451 if !alreadyMatched { 452 return fmt.Errorf("original dependency version does not match patch: %s %q != %q", name, ver, origVer) 453 } 454 // dependency was already matched, so we can ignore it. 455 } else { 456 manif, err = sjson.SetBytes(manif, depStr, newVer) 457 if err != nil { 458 return err 459 } 460 } 461 } 462 } 463 } 464 465 // Write the patched manifest to the output path. 466 if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { 467 return err 468 } 469 if err := os.WriteFile(outputPath, manif, 0644); err != nil { 470 return err 471 } 472 473 return nil 474 }