github.com/joey-fossa/fossa-cli@v0.7.34-0.20190708193710-569f1e8679f0/analyzers/nodejs/nodejs.go (about)

     1  // Package nodejs provides analyzers for Node.js projects.
     2  //
     3  // A Node.js project is defined as any folder with a `package.json`. A project
     4  // may or may not have dependencies.
     5  //
     6  // A `BuildTarget` for Node.js is defined as the relative path to the directory
     7  // containing the `package.json`, and the `Dir` is defined as the CWD for
     8  // running build tools (like `npm` or `yarn`).
     9  //
    10  // `npm` and `yarn` are explicitly supported as first-class tools. Where
    11  // possible, these tools are queried before falling back to other strategies.
    12  //
    13  // All Node.js projects are implicitly supported via `node_modules` parsing.
    14  package nodejs
    15  
    16  import (
    17  	"os"
    18  	"path/filepath"
    19  	"strings"
    20  
    21  	"github.com/apex/log"
    22  	"github.com/mitchellh/mapstructure"
    23  	"github.com/pkg/errors"
    24  
    25  	"github.com/fossas/fossa-cli/buildtools/npm"
    26  	"github.com/fossas/fossa-cli/buildtools/yarn"
    27  	"github.com/fossas/fossa-cli/exec"
    28  	"github.com/fossas/fossa-cli/files"
    29  	"github.com/fossas/fossa-cli/graph"
    30  	"github.com/fossas/fossa-cli/module"
    31  	"github.com/fossas/fossa-cli/pkg"
    32  )
    33  
    34  type Analyzer struct {
    35  	NodeVersion string
    36  
    37  	NPM  npm.NPM
    38  	Yarn yarn.YarnTool
    39  
    40  	Module  module.Module
    41  	Options Options
    42  }
    43  
    44  // Options contains options for the `Analyzer`.
    45  //
    46  // The analyzer can use many different strategies. These are:
    47  //
    48  //   - `yarn`: Run and parse `yarn ls --json`.
    49  //   - `npm`: Run and parse `npm ls --json`.
    50  //   - `yarn.lock`: Parse `./yarn.lock`.
    51  //   - `package-lock.json`: Parse `./package-lock.json`.
    52  //   - `node_modules`: Parse `./package.json` and recursively look up
    53  // 		 dependencies with `node_modules` resolution.
    54  //   - `node_modules_local`: Parse manifests in `./node_modules``.
    55  //   - `package.json`: Parse `./package.json`.
    56  //
    57  // If no strategies are specified, the analyzer will try each of these
    58  // strategies in descending order.
    59  type Options struct {
    60  	Strategy    string `mapstructure:"strategy"`
    61  	AllowNPMErr bool   `mapstructure:"allow-npm-err"`
    62  }
    63  
    64  // New configures Node, NPM, and Yarn commands.
    65  func New(m module.Module) (*Analyzer, error) {
    66  	log.WithField("options", m.Options).Debug("constructing analyzer")
    67  
    68  	_, nodeVersion, nodeErr := exec.Which("-v", os.Getenv("FOSSA_NODE_CMD"), "node", "nodejs")
    69  	if nodeErr != nil {
    70  		log.Warnf("Could not find Node.JS: %s", nodeErr.Error())
    71  	}
    72  
    73  	var options Options
    74  	err := mapstructure.Decode(m.Options, &options)
    75  	if err != nil {
    76  		return nil, err
    77  	}
    78  
    79  	npmTool, err := npm.New()
    80  	if err != nil {
    81  		log.Warn("Could not initialize npm tooling")
    82  	}
    83  
    84  	yarnTool, err := yarn.New()
    85  	if err != nil {
    86  		log.Warn("Could not initialze yarn tooling")
    87  	}
    88  
    89  	analyzer := Analyzer{
    90  		NodeVersion: nodeVersion,
    91  
    92  		NPM:  npmTool,
    93  		Yarn: yarnTool,
    94  
    95  		Module:  m,
    96  		Options: options,
    97  	}
    98  
    99  	log.Debugf("Initialized Node.js analyzer: %#v", analyzer)
   100  	return &analyzer, nil
   101  }
   102  
   103  // Discover searches for `package.json`s not within a `node_modules` or
   104  // `bower_components`.
   105  func Discover(dir string, options map[string]interface{}) ([]module.Module, error) {
   106  	log.WithField("dir", dir).Debug("discovering modules")
   107  	var modules []module.Module
   108  	err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
   109  		if err != nil {
   110  			log.WithError(err).WithField("path", path).Debug("error while walking for discovery")
   111  			return err
   112  		}
   113  
   114  		// Skip **/node_modules and **/bower_components
   115  		if info.IsDir() && (info.Name() == "node_modules" || info.Name() == "bower_components") {
   116  			log.Debugf("Skipping directory: %s", info.Name())
   117  			return filepath.SkipDir
   118  		}
   119  
   120  		if !info.IsDir() && info.Name() == "package.json" {
   121  			name := filepath.Base(filepath.Dir(path))
   122  			// Parse from project name from `package.json` if possible
   123  			if manifest, err := npm.FromManifest(path, "package.json"); err == nil && manifest.Name != "" {
   124  				name = manifest.Name
   125  			}
   126  
   127  			log.Debugf("Found NodeJS project: %s (%s)", path, name)
   128  			path, err = filepath.Rel(dir, path)
   129  			if err != nil {
   130  				panic(err)
   131  			}
   132  			modules = append(modules, module.Module{
   133  				Name:        name,
   134  				Type:        pkg.NodeJS,
   135  				BuildTarget: filepath.Dir(path),
   136  				Dir:         filepath.Dir(path),
   137  			})
   138  		}
   139  		return nil
   140  	})
   141  
   142  	if err != nil {
   143  		return nil, errors.Wrap(err, "could not find NodeJS projects")
   144  	}
   145  
   146  	return modules, nil
   147  }
   148  
   149  // Clean removes `node_modules`.
   150  func (a *Analyzer) Clean() error {
   151  	return files.Rm(a.Module.Dir, "node_modules")
   152  }
   153  
   154  // Build runs `yarn install --production --frozen-lockfile` if there exists a
   155  // `yarn.lock` and `yarn` is available. Otherwise, it runs
   156  // `npm install --production`.
   157  func (a *Analyzer) Build() error {
   158  	log.Debugf("Running Node.js build: %#v", a.Module)
   159  
   160  	// Prefer Yarn where possible.
   161  	if ok, err := files.Exists(a.Module.Dir, "yarn.lock"); err == nil && ok && a.Yarn.Exists() {
   162  		err := a.Yarn.Install(a.Module.Dir)
   163  		if err != nil {
   164  			return errors.Wrap(err, "could not run `yarn` build")
   165  		}
   166  	} else {
   167  		if !a.NPM.Exists() {
   168  			return errors.New("attempting to build using npm without npm tooling")
   169  		}
   170  
   171  		err := a.NPM.Install(a.Module.Dir)
   172  		if err != nil {
   173  			return errors.Wrap(err, "could not run `npm` build")
   174  		}
   175  	}
   176  
   177  	log.Debug("Done running Node.js build.")
   178  	return nil
   179  }
   180  
   181  // IsBuilt returns true if a project has a manifest and either has no
   182  // dependencies or has a `node_modules` folder.
   183  //
   184  // Note that there could be very strange builds where this will produce false
   185  // negatives (e.g. `node_modules` exists in a parent folder). There can also
   186  // exist builds where this will produce false positives (e.g. `node_modules`
   187  // folder does not include the correct dependencies). We also don't take
   188  // $NODE_PATH into account during resolution.
   189  //
   190  // TODO: with significantly more effort, we can eliminate both of these
   191  // situations.
   192  func (a *Analyzer) IsBuilt() (bool, error) {
   193  	a.Module.BuildTarget = fixLegacyBuildTarget(a.Module.BuildTarget)
   194  	log.Debugf("Checking Node.js build: %#v", a.Module)
   195  
   196  	manifest, err := npm.FromManifest(a.Module.BuildTarget, "package.json")
   197  	if err != nil {
   198  		return false, errors.Wrap(err, "could not parse package manifest to check build")
   199  	}
   200  
   201  	if len(manifest.Dependencies) == 0 {
   202  		log.Debugf("Done checking Node.js build: project has no dependencies")
   203  		return true, nil
   204  	}
   205  
   206  	hasNodeModules, err := files.ExistsFolder(a.Module.Dir, "node_modules")
   207  	if err != nil {
   208  		return false, err
   209  	}
   210  
   211  	log.Debugf("Done checking Node.js build: %#v", hasNodeModules)
   212  	return hasNodeModules, nil
   213  }
   214  
   215  func (a *Analyzer) Analyze() (graph.Deps, error) {
   216  	a.Module.BuildTarget = fixLegacyBuildTarget(a.Module.BuildTarget)
   217  	log.Debugf("Running Nodejs analysis: %#v", a.Module)
   218  	// if npm as a tool does not exist, skip this
   219  	if a.NPM.Exists() {
   220  		pkgs, err := a.NPM.List(a.Module.BuildTarget)
   221  		if err == nil {
   222  			// TODO: we should move this functionality in to the buildtool, and have it
   223  			// return `pkg.Package`s.
   224  			// Set direct dependencies.
   225  			var imports []pkg.Import
   226  			for name, dep := range pkgs.Dependencies {
   227  				imports = append(imports, pkg.Import{
   228  					Target: dep.From,
   229  					Resolved: pkg.ID{
   230  						Type:     pkg.NodeJS,
   231  						Name:     name,
   232  						Revision: dep.Version,
   233  						Location: dep.Resolved,
   234  					},
   235  				})
   236  			}
   237  
   238  			// Set transitive dependencies.
   239  			deps := make(map[pkg.ID]pkg.Package)
   240  			recurseDeps(deps, pkgs)
   241  
   242  			log.Debugf("Done running Nodejs analysis: %#v", deps)
   243  
   244  			return graph.Deps{
   245  				Direct:     imports,
   246  				Transitive: deps,
   247  			}, nil
   248  		}
   249  
   250  		log.Warnf("NPM had non-zero exit code: %s", err.Error())
   251  		log.Debug("Using fallback of node_modules")
   252  	}
   253  
   254  	deps, err := npm.FromNodeModules(a.Module.BuildTarget, "package.json")
   255  	if err == nil {
   256  		return deps, nil
   257  	}
   258  
   259  	log.Warnf("Could not determine deps from node_modules")
   260  	log.Debug("Using fallback of lockfile check")
   261  
   262  	// currently only support yarn.lock
   263  	return yarn.FromProject(filepath.Join(a.Module.BuildTarget, "package.json"), filepath.Join(a.Module.BuildTarget, "yarn.lock"))
   264  }
   265  
   266  // fixLegacyBuildTarget ensures that legacy behavior stays intact but users are warned if it is implemented.
   267  func fixLegacyBuildTarget(target string) string {
   268  	if strings.HasSuffix(target, string(filepath.Separator)+"package.json") {
   269  		log.Warn("Specifying the package.json file as a module's target in fossa's config file is no longer supported. Instead, the target should specify the path to the project folder.")
   270  		target = strings.TrimSuffix(target, string(filepath.Separator)+"package.json")
   271  	}
   272  	if strings.HasSuffix(target, string(filepath.Separator)) {
   273  		log.Warn("Trailing slashes in module targets are not suppoorted.")
   274  		target = strings.TrimSuffix(target, string(filepath.Separator))
   275  	}
   276  	return target
   277  }
   278  
   279  // TODO: implement this generically in package graph (Bower also has an
   280  // implementation)
   281  func recurseDeps(pkgMap map[pkg.ID]pkg.Package, p npm.Output) {
   282  	for name, dep := range p.Dependencies {
   283  		// Construct ID.
   284  		id := pkg.ID{
   285  			Type:     pkg.NodeJS,
   286  			Name:     name,
   287  			Revision: dep.Version,
   288  			Location: dep.Resolved,
   289  		}
   290  		// Handle previously seen (usually deduplicated) entries: see #257.
   291  		previous := pkgMap[id]
   292  
   293  		// Set direct imports.
   294  		var imports []pkg.Import
   295  		for name, i := range dep.Dependencies {
   296  			imports = append(imports, pkg.Import{
   297  				Target: i.From,
   298  				Resolved: pkg.ID{
   299  					Type:     pkg.NodeJS,
   300  					Name:     name,
   301  					Revision: i.Version,
   302  					Location: i.Resolved,
   303  				},
   304  			})
   305  		}
   306  		// Update map.
   307  		// NOTE: We're assuming that each deduplicated dependency's imports will
   308  		// only be listed once. This assumption might not be true. If it's not, then
   309  		// we need to do a set union instead of a list concatenation.
   310  		pkgMap[id] = pkg.Package{
   311  			ID:      id,
   312  			Imports: append(imports, previous.Imports...),
   313  		}
   314  		// Recurse in imports.
   315  		recurseDeps(pkgMap, dep)
   316  	}
   317  }