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  }