github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/javascript/package.go (about)

     1  package javascript
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"net/http"
     9  	"net/url"
    10  	"path"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/anchore/packageurl-go"
    15  	"github.com/anchore/syft/internal"
    16  	"github.com/anchore/syft/internal/log"
    17  	"github.com/anchore/syft/syft/file"
    18  	"github.com/anchore/syft/syft/pkg"
    19  	"github.com/anchore/syft/syft/pkg/cataloger/internal/licenses"
    20  )
    21  
    22  func newPackageJSONPackage(ctx context.Context, resolver file.Resolver, u packageJSON, indexLocation file.Location) pkg.Package {
    23  	licenseCandidates, err := u.licensesFromJSON()
    24  	if err != nil {
    25  		log.Debugf("unable to extract licenses from javascript package.json: %+v", err)
    26  	}
    27  
    28  	license := pkg.NewLicensesFromLocationWithContext(ctx, indexLocation, licenseCandidates...)
    29  	// Handle author, authors, contributors, and maintainers fields
    30  	var authorParts []string
    31  
    32  	// Add a single author field if it exists
    33  	if u.Author.Name != "" || u.Author.Email != "" || u.Author.URL != "" {
    34  		if authStr := u.Author.AuthorString(); authStr != "" {
    35  			authorParts = append(authorParts, authStr)
    36  		}
    37  	}
    38  
    39  	// Add authors field if it exists
    40  	if len(u.Authors) > 0 {
    41  		if authorsStr := u.Authors.String(); authorsStr != "" {
    42  			authorParts = append(authorParts, authorsStr)
    43  		}
    44  	}
    45  
    46  	// Add contributors field if it exists
    47  	if len(u.Contributors) > 0 {
    48  		if contributorsStr := u.Contributors.String(); contributorsStr != "" {
    49  			authorParts = append(authorParts, contributorsStr)
    50  		}
    51  	}
    52  
    53  	// Add maintainers field if it exists
    54  	if len(u.Maintainers) > 0 {
    55  		if maintainersStr := u.Maintainers.String(); maintainersStr != "" {
    56  			authorParts = append(authorParts, maintainersStr)
    57  		}
    58  	}
    59  
    60  	authorInfo := strings.Join(authorParts, ", ")
    61  
    62  	p := pkg.Package{
    63  		Name:      u.Name,
    64  		Version:   u.Version,
    65  		PURL:      packageURL(u.Name, u.Version),
    66  		Locations: file.NewLocationSet(indexLocation),
    67  		Language:  pkg.JavaScript,
    68  		Licenses:  pkg.NewLicenseSet(license...),
    69  		Type:      pkg.NpmPkg,
    70  		Metadata: pkg.NpmPackage{
    71  			Name:        u.Name,
    72  			Version:     u.Version,
    73  			Description: u.Description,
    74  			Author:      authorInfo,
    75  			Homepage:    u.Homepage,
    76  			URL:         u.Repository.URL,
    77  			Private:     u.Private,
    78  		},
    79  	}
    80  
    81  	p.SetID()
    82  
    83  	// if license not specified, search for license files
    84  	p = licenses.RelativeToPackage(ctx, resolver, p)
    85  
    86  	return p
    87  }
    88  
    89  func newPackageLockV1Package(ctx context.Context, cfg CatalogerConfig, resolver file.Resolver, location file.Location, name string, u lockDependency) pkg.Package {
    90  	version := u.Version
    91  
    92  	const aliasPrefixPackageLockV1 = "npm:"
    93  
    94  	// Handles type aliases https://github.com/npm/rfcs/blob/main/implemented/0001-package-aliases.md
    95  	if strings.HasPrefix(version, aliasPrefixPackageLockV1) {
    96  		// this is an alias.
    97  		// `"version": "npm:canonical-name@X.Y.Z"`
    98  		canonicalPackageAndVersion := version[len(aliasPrefixPackageLockV1):]
    99  		versionSeparator := strings.LastIndex(canonicalPackageAndVersion, "@")
   100  
   101  		name = canonicalPackageAndVersion[:versionSeparator]
   102  		version = canonicalPackageAndVersion[versionSeparator+1:]
   103  	}
   104  
   105  	var licenseSet pkg.LicenseSet
   106  
   107  	if cfg.SearchRemoteLicenses {
   108  		license, err := getLicenseFromNpmRegistry(cfg.NPMBaseURL, name, version)
   109  		if err == nil && license != "" {
   110  			licenseSet = pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...)
   111  		}
   112  		if err != nil {
   113  			log.Debugf("unable to extract licenses from javascript package-lock.json for package %s:%s: %+v", name, version, err)
   114  		}
   115  	}
   116  
   117  	return finalizeLockPkg(
   118  		ctx,
   119  		resolver,
   120  		location,
   121  		pkg.Package{
   122  			Name:      name,
   123  			Version:   version,
   124  			Licenses:  licenseSet,
   125  			Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
   126  			PURL:      packageURL(name, version),
   127  			Language:  pkg.JavaScript,
   128  			Type:      pkg.NpmPkg,
   129  			Metadata:  pkg.NpmPackageLockEntry{Resolved: u.Resolved, Integrity: u.Integrity},
   130  		},
   131  	)
   132  }
   133  
   134  func newPackageLockV2Package(ctx context.Context, cfg CatalogerConfig, resolver file.Resolver, location file.Location, name string, u lockPackage) pkg.Package {
   135  	var licenseSet pkg.LicenseSet
   136  
   137  	if u.License != nil {
   138  		licenseSet = pkg.NewLicenseSet(pkg.NewLicensesFromLocationWithContext(ctx, location, u.License...)...)
   139  	} else if cfg.SearchRemoteLicenses {
   140  		license, err := getLicenseFromNpmRegistry(cfg.NPMBaseURL, name, u.Version)
   141  		if err == nil && license != "" {
   142  			licenseSet = pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...)
   143  		}
   144  		if err != nil {
   145  			log.Debugf("unable to extract licenses from javascript package-lock.json for package %s:%s: %+v", name, u.Version, err)
   146  		}
   147  	}
   148  
   149  	return finalizeLockPkg(
   150  		ctx,
   151  		resolver,
   152  		location,
   153  		pkg.Package{
   154  			Name:      name,
   155  			Version:   u.Version,
   156  			Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
   157  			Licenses:  licenseSet,
   158  			PURL:      packageURL(name, u.Version),
   159  			Language:  pkg.JavaScript,
   160  			Type:      pkg.NpmPkg,
   161  			Metadata:  pkg.NpmPackageLockEntry{Resolved: u.Resolved, Integrity: u.Integrity, Dependencies: u.Dependencies},
   162  		},
   163  	)
   164  }
   165  
   166  func newPnpmPackage(ctx context.Context, cfg CatalogerConfig, resolver file.Resolver, location file.Location, name, version string, integrity string, dependencies map[string]string) pkg.Package {
   167  	var licenseSet pkg.LicenseSet
   168  
   169  	if cfg.SearchRemoteLicenses {
   170  		license, err := getLicenseFromNpmRegistry(cfg.NPMBaseURL, name, version)
   171  		if err == nil && license != "" {
   172  			licenseSet = pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...)
   173  		}
   174  		if err != nil {
   175  			log.Debugf("unable to extract licenses from javascript pnpm-lock.yaml for package %s:%s: %+v", name, version, err)
   176  		}
   177  	}
   178  	return finalizeLockPkg(
   179  		ctx,
   180  		resolver,
   181  		location,
   182  		pkg.Package{
   183  			Name:      name,
   184  			Version:   version,
   185  			Licenses:  licenseSet,
   186  			Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
   187  			PURL:      packageURL(name, version),
   188  			Language:  pkg.JavaScript,
   189  			Type:      pkg.NpmPkg,
   190  			Metadata:  pkg.PnpmLockEntry{Resolution: pkg.PnpmLockResolution{Integrity: integrity}, Dependencies: dependencies},
   191  		},
   192  	)
   193  }
   194  
   195  func newYarnLockPackage(ctx context.Context, cfg CatalogerConfig, resolver file.Resolver, location file.Location, name, version string, resolved string, integrity string, dependencies map[string]string) pkg.Package {
   196  	var licenseSet pkg.LicenseSet
   197  
   198  	if cfg.SearchRemoteLicenses {
   199  		license, err := getLicenseFromNpmRegistry(cfg.NPMBaseURL, name, version)
   200  		if err == nil && license != "" {
   201  			licenseSet = pkg.NewLicenseSet(pkg.NewLicensesFromValuesWithContext(ctx, license)...)
   202  		}
   203  		if err != nil {
   204  			log.Debugf("unable to extract licenses from javascript yarn.lock for package %s:%s: %+v", name, version, err)
   205  		}
   206  	}
   207  	return finalizeLockPkg(
   208  		ctx,
   209  		resolver,
   210  		location,
   211  		pkg.Package{
   212  			Name:      name,
   213  			Version:   version,
   214  			Licenses:  licenseSet,
   215  			Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
   216  			PURL:      packageURL(name, version),
   217  			Language:  pkg.JavaScript,
   218  			Type:      pkg.NpmPkg,
   219  			Metadata:  pkg.YarnLockEntry{Resolved: resolved, Integrity: integrity, Dependencies: dependencies},
   220  		},
   221  	)
   222  }
   223  
   224  func formatNpmRegistryURL(baseURL, packageName, version string) (requestURL string, err error) {
   225  	urlPath := []string{packageName, version}
   226  	requestURL, err = url.JoinPath(baseURL, urlPath...)
   227  	if err != nil {
   228  		return requestURL, fmt.Errorf("unable to format npm request for pkg:version %s%s; %w", packageName, version, err)
   229  	}
   230  	return requestURL, nil
   231  }
   232  
   233  func getLicenseFromNpmRegistry(baseURL, packageName, version string) (string, error) {
   234  	// "https://registry.npmjs.org/%s/%s", packageName, version
   235  	requestURL, err := formatNpmRegistryURL(baseURL, packageName, version)
   236  	if err != nil {
   237  		return "", fmt.Errorf("unable to format npm request for pkg:version %s%s; %w", packageName, version, err)
   238  	}
   239  	log.WithFields("url", requestURL).Info("downloading javascript package from npm")
   240  
   241  	npmRequest, err := http.NewRequest(http.MethodGet, requestURL, nil)
   242  	if err != nil {
   243  		return "", fmt.Errorf("unable to format remote request: %w", err)
   244  	}
   245  
   246  	httpClient := &http.Client{
   247  		Timeout: time.Second * 10,
   248  	}
   249  
   250  	resp, err := httpClient.Do(npmRequest)
   251  	if err != nil {
   252  		return "", fmt.Errorf("unable to get package from npm registry: %w", err)
   253  	}
   254  	defer func() {
   255  		if err := resp.Body.Close(); err != nil {
   256  			log.Errorf("unable to close body: %+v", err)
   257  		}
   258  	}()
   259  
   260  	bytes, err := io.ReadAll(resp.Body)
   261  	if err != nil {
   262  		return "", fmt.Errorf("unable to parse package from npm registry: %w", err)
   263  	}
   264  
   265  	dec := json.NewDecoder(strings.NewReader(string(bytes)))
   266  
   267  	// Read "license" from the response
   268  	var license struct {
   269  		License string `json:"license"`
   270  	}
   271  
   272  	if err := dec.Decode(&license); err != nil {
   273  		return "", fmt.Errorf("unable to parse license from npm registry: %w", err)
   274  	}
   275  
   276  	log.Tracef("Retrieved License: %s", license.License)
   277  
   278  	return license.License, nil
   279  }
   280  
   281  func finalizeLockPkg(ctx context.Context, resolver file.Resolver, location file.Location, p pkg.Package) pkg.Package {
   282  	licenseCandidate := addLicenses(p.Name, resolver, location)
   283  	p.Licenses.Add(pkg.NewLicensesFromLocationWithContext(ctx, location, licenseCandidate...)...)
   284  	p.SetID()
   285  	return p
   286  }
   287  
   288  func addLicenses(name string, resolver file.Resolver, location file.Location) (allLicenses []string) {
   289  	if resolver == nil {
   290  		return allLicenses
   291  	}
   292  
   293  	dir := path.Dir(location.RealPath)
   294  	pkgPath := []string{dir, "node_modules"}
   295  	pkgPath = append(pkgPath, strings.Split(name, "/")...)
   296  	pkgPath = append(pkgPath, "package.json")
   297  	pkgFile := path.Join(pkgPath...)
   298  	locations, err := resolver.FilesByPath(pkgFile)
   299  	if err != nil {
   300  		log.Debugf("an error occurred attempting to read: %s - %+v", pkgFile, err)
   301  		return allLicenses
   302  	}
   303  
   304  	if len(locations) == 0 {
   305  		return allLicenses
   306  	}
   307  
   308  	for _, l := range locations {
   309  		foundLicenses, err := parseLicensesFromLocation(l, resolver, pkgFile)
   310  		if err != nil {
   311  			return allLicenses
   312  		}
   313  		allLicenses = append(allLicenses, foundLicenses...)
   314  	}
   315  
   316  	return allLicenses
   317  }
   318  
   319  func parseLicensesFromLocation(l file.Location, resolver file.Resolver, pkgFile string) ([]string, error) {
   320  	contentReader, err := resolver.FileContentsByLocation(l)
   321  	if err != nil {
   322  		log.Debugf("error getting file content reader for %s: %v", pkgFile, err)
   323  		return nil, err
   324  	}
   325  	defer internal.CloseAndLogError(contentReader, l.RealPath)
   326  
   327  	contents, err := io.ReadAll(contentReader)
   328  	if err != nil {
   329  		log.Debugf("error reading file contents for %s: %v", pkgFile, err)
   330  		return nil, err
   331  	}
   332  
   333  	var pkgJSON packageJSON
   334  	err = json.Unmarshal(contents, &pkgJSON)
   335  	if err != nil {
   336  		log.Debugf("error parsing %s: %v", pkgFile, err)
   337  		return nil, err
   338  	}
   339  
   340  	out, err := pkgJSON.licensesFromJSON()
   341  	if err != nil {
   342  		log.Debugf("error getting licenses from %s: %v", pkgFile, err)
   343  		return nil, err
   344  	}
   345  	return out, nil
   346  }
   347  
   348  // packageURL returns the PURL for the specific NPM package (see https://github.com/package-url/purl-spec)
   349  func packageURL(name, version string) string {
   350  	var namespace string
   351  
   352  	fields := strings.SplitN(name, "/", 2)
   353  	if len(fields) > 1 {
   354  		namespace = fields[0]
   355  		name = fields[1]
   356  	}
   357  
   358  	return packageurl.NewPackageURL(
   359  		packageurl.TypeNPM,
   360  		namespace,
   361  		name,
   362  		version,
   363  		nil,
   364  		"",
   365  	).ToString()
   366  }