mirror of
https://github.com/anchore/syft.git
synced 2026-04-05 22:30:35 +02:00
--------- Signed-off-by: Rez Moss <hi@rezmoss.com> Signed-off-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com> Co-authored-by: Christopher Phillips <32073428+spiffcs@users.noreply.github.com>
324 lines
10 KiB
Go
324 lines
10 KiB
Go
package javascript
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
"github.com/scylladb/go-set/strset"
|
|
|
|
"github.com/anchore/syft/internal"
|
|
"github.com/anchore/syft/internal/log"
|
|
"github.com/anchore/syft/internal/unknown"
|
|
"github.com/anchore/syft/syft/artifact"
|
|
"github.com/anchore/syft/syft/file"
|
|
"github.com/anchore/syft/syft/pkg"
|
|
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
|
"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
|
|
)
|
|
|
|
var (
|
|
// packageNameExp matches the name of the dependency in yarn.lock
|
|
// including scope/namespace prefix if found.
|
|
// For example: "aws-sdk@2.706.0" returns "aws-sdk"
|
|
// "@babel/code-frame@^7.0.0" returns "@babel/code-frame"
|
|
packageNameExp = regexp.MustCompile(`^"?((?:@\w[\w-_.]*\/)?\w[\w-_.]*)@`)
|
|
|
|
// packageURLExp matches the name and version of the dependency in yarn.lock
|
|
// from the resolved URL, including scope/namespace prefix if any.
|
|
// For example:
|
|
// `resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9"`
|
|
// would return "async" and "3.2.3"
|
|
//
|
|
// `resolved "https://registry.yarnpkg.com/@4lolo/resize-observer-polyfill/-/resize-observer-polyfill-1.5.2.tgz#58868fc7224506236b5550d0c68357f0a874b84b"`
|
|
// would return "@4lolo/resize-observer-polyfill" and "1.5.2"
|
|
packageURLExp = regexp.MustCompile(`^resolved\s+"https://registry\.(?:yarnpkg\.com|npmjs\.org)/(.+?)/-/(?:.+?)-(\d+\..+?)\.tgz`)
|
|
|
|
// resolvedExp matches the resolved of the dependency in yarn.lock
|
|
// For example:
|
|
// resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
|
|
// would return "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
|
|
resolvedExp = regexp.MustCompile(`^resolved\s+"(.+?)"`)
|
|
)
|
|
|
|
type yarnPackage struct {
|
|
Name string
|
|
Version string
|
|
Resolved string
|
|
Integrity string
|
|
Dependencies map[string]string
|
|
}
|
|
|
|
type yarnV2PackageEntry struct {
|
|
Version string `yaml:"version"`
|
|
Resolution string `yaml:"resolution"`
|
|
Checksum string `yaml:"checksum"`
|
|
Dependencies map[string]string `yaml:"dependencies"`
|
|
}
|
|
|
|
type genericYarnLockAdapter struct {
|
|
cfg CatalogerConfig
|
|
}
|
|
|
|
func newGenericYarnLockAdapter(cfg CatalogerConfig) genericYarnLockAdapter {
|
|
return genericYarnLockAdapter{
|
|
cfg: cfg,
|
|
}
|
|
}
|
|
|
|
// readPackageJSONDeps reads the package.json adjacent to the given lockfile location.
|
|
// NOTE: in yarn workspaces, only the root package.json is consulted. Packages declared
|
|
// as devDependencies only in a workspace package.json (not the root) will not be detected
|
|
// as dev-only. This is a safe degradation — those packages will be included in the SBOM
|
|
// rather than incorrectly filtered out.
|
|
func readPackageJSONDeps(resolver file.Resolver, lockfileLocation file.Location) (prod, dev map[string]string) {
|
|
prod = make(map[string]string)
|
|
dev = make(map[string]string)
|
|
|
|
if resolver == nil {
|
|
return prod, dev
|
|
}
|
|
|
|
pkgJSONLocation := resolver.RelativeFileByPath(lockfileLocation, "package.json")
|
|
if pkgJSONLocation == nil {
|
|
log.WithFields("lockfile", lockfileLocation.RealPath).Debug("could not find package.json for dev dependency detection")
|
|
return prod, dev
|
|
}
|
|
|
|
reader, err := resolver.FileContentsByLocation(*pkgJSONLocation)
|
|
if err != nil {
|
|
log.WithFields("location", pkgJSONLocation.RealPath, "error", err).Debug("could not read package.json for dev dependency detection")
|
|
return prod, dev
|
|
}
|
|
defer internal.CloseAndLogError(reader, pkgJSONLocation.RealPath)
|
|
|
|
var pkgJSON packageJSON
|
|
if err := json.NewDecoder(reader).Decode(&pkgJSON); err != nil {
|
|
log.WithFields("location", pkgJSONLocation.RealPath, "error", err).Debug("could not parse package.json for dev dependency detection")
|
|
return prod, dev
|
|
}
|
|
|
|
if pkgJSON.Dependencies != nil {
|
|
prod = pkgJSON.Dependencies
|
|
}
|
|
if pkgJSON.DevDependencies != nil {
|
|
dev = pkgJSON.DevDependencies
|
|
}
|
|
|
|
return prod, dev
|
|
}
|
|
|
|
// findReachable returns all package names reachable from the given roots via BFS
|
|
// through the dependency graph.
|
|
func findReachable(roots map[string]string, pkgByName map[string]yarnPackage) map[string]bool {
|
|
visited := make(map[string]bool)
|
|
queue := make([]string, 0, len(roots))
|
|
|
|
for name := range roots {
|
|
queue = append(queue, name)
|
|
}
|
|
|
|
for len(queue) > 0 {
|
|
name := queue[0]
|
|
queue = queue[1:]
|
|
|
|
if visited[name] {
|
|
continue
|
|
}
|
|
visited[name] = true
|
|
|
|
if pkg, exists := pkgByName[name]; exists {
|
|
for depName := range pkg.Dependencies {
|
|
if !visited[depName] {
|
|
queue = append(queue, depName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return visited
|
|
}
|
|
|
|
func findDevOnlyPkgs(yarnPkgs []yarnPackage, prodDeps, devDeps map[string]string) map[string]bool {
|
|
pkgByName := make(map[string]yarnPackage)
|
|
for _, p := range yarnPkgs {
|
|
pkgByName[p.Name] = p
|
|
}
|
|
|
|
prodTransitive := findReachable(prodDeps, pkgByName)
|
|
devTransitive := findReachable(devDeps, pkgByName)
|
|
|
|
devOnly := make(map[string]bool)
|
|
for name := range devTransitive {
|
|
if !prodTransitive[name] {
|
|
devOnly[name] = true
|
|
}
|
|
}
|
|
|
|
return devOnly
|
|
}
|
|
|
|
func parseYarnV1LockFile(reader io.ReadCloser) ([]yarnPackage, error) {
|
|
content, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read yarn.lock file: %w", err)
|
|
}
|
|
|
|
re := regexp.MustCompile(`\r?\n`)
|
|
lines := re.Split(string(content), -1)
|
|
var pkgs []yarnPackage
|
|
var pkg = yarnPackage{}
|
|
var seenPkgs = strset.New()
|
|
dependencies := make(map[string]string)
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
// Blank lines indicate the end of a package entry, so we add the package
|
|
// to the list and reset the dependencies
|
|
if len(line) == 0 && len(pkg.Name) > 0 && !seenPkgs.Has(pkg.Name+"@"+pkg.Version) {
|
|
pkg.Dependencies = dependencies
|
|
pkgs = append(pkgs, pkg)
|
|
seenPkgs.Add(pkg.Name + "@" + pkg.Version)
|
|
dependencies = make(map[string]string)
|
|
pkg = yarnPackage{}
|
|
continue
|
|
}
|
|
// The first line of a package entry is the name of the package with no
|
|
// leading spaces
|
|
if !strings.HasPrefix(line, " ") {
|
|
name := line
|
|
pkg.Name = findPackageName(name)
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") {
|
|
line = strings.Trim(line, " ")
|
|
array := strings.Split(line, " ")
|
|
switch array[0] {
|
|
case "version":
|
|
pkg.Version = strings.Trim(array[1], "\"")
|
|
case "resolved":
|
|
name, version, resolved := findResolvedPackageAndVersion(line)
|
|
if name != "" && version != "" && resolved != "" {
|
|
pkg.Name = name
|
|
pkg.Version = version
|
|
pkg.Resolved = resolved
|
|
} else {
|
|
pkg.Resolved = strings.Trim(array[1], "\"")
|
|
}
|
|
case "integrity":
|
|
pkg.Integrity = strings.Trim(array[1], "\"")
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, " ") {
|
|
line = strings.Trim(line, " ")
|
|
array := strings.Split(line, " ")
|
|
dependencyName := strings.Trim(array[0], "\"")
|
|
dependencyVersion := strings.Trim(array[1], "\"")
|
|
dependencies[dependencyName] = dependencyVersion
|
|
}
|
|
}
|
|
// If the last package in the list is not the same as the current package, add the current package
|
|
// to the list. In case there was no trailing new line before we hit EOF.
|
|
if len(pkg.Name) > 0 && !seenPkgs.Has(pkg.Name+"@"+pkg.Version) {
|
|
pkg.Dependencies = dependencies
|
|
pkgs = append(pkgs, pkg)
|
|
seenPkgs.Add(pkg.Name + "@" + pkg.Version)
|
|
}
|
|
|
|
return pkgs, nil
|
|
}
|
|
|
|
func parseYarnLockYaml(reader io.ReadCloser) ([]yarnPackage, error) {
|
|
var lockfile = map[string]yarnV2PackageEntry{}
|
|
if err := yaml.NewDecoder(reader, yaml.AllowDuplicateMapKey()).Decode(&lockfile); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal yarn v2 lockfile: %w", err)
|
|
}
|
|
|
|
packages := make(map[string]yarnPackage)
|
|
for key, value := range lockfile {
|
|
packageName := findPackageName(key)
|
|
if packageName == "" {
|
|
log.WithFields("key", key).Error("unable to parse yarn v2 package key")
|
|
continue
|
|
}
|
|
|
|
packages[packageName] = yarnPackage{Name: packageName, Version: value.Version, Resolved: value.Resolution, Integrity: value.Checksum, Dependencies: value.Dependencies}
|
|
}
|
|
|
|
return slices.Collect(maps.Values(packages)), nil
|
|
}
|
|
|
|
func (a genericYarnLockAdapter) parseYarnLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
|
// in the case we find yarn.lock files in the node_modules directories, skip those
|
|
// as the whole purpose of the lock file is for the specific dependencies of the project
|
|
if pathContainsNodeModulesDirectory(reader.Path()) {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
data, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to load yarn.lock file: %w", err)
|
|
}
|
|
// Reset the reader to the beginning of the file
|
|
reader.ReadCloser = io.NopCloser(bytes.NewBuffer(data))
|
|
|
|
var yarnPkgs []yarnPackage
|
|
// v1 Yarn lockfiles are not YAML, so we need to parse them as a special case. They typically
|
|
// include a comment line that indicates the version. I.e. "# yarn lockfile v1"
|
|
if strings.Contains(string(data), "# yarn lockfile v1") {
|
|
yarnPkgs, err = parseYarnV1LockFile(reader)
|
|
} else {
|
|
yarnPkgs, err = parseYarnLockYaml(reader)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to parse yarn.lock file: %w", err)
|
|
}
|
|
|
|
// get dependencies from sibling package.json for dev dependency detection
|
|
prodDeps, devDeps := readPackageJSONDeps(resolver, reader.Location)
|
|
|
|
devOnlyPkgs := findDevOnlyPkgs(yarnPkgs, prodDeps, devDeps)
|
|
|
|
packages := make([]pkg.Package, 0, len(yarnPkgs))
|
|
for _, p := range yarnPkgs {
|
|
if devOnlyPkgs[p.Name] && !a.cfg.IncludeDevDependencies {
|
|
continue
|
|
}
|
|
packages = append(packages, newYarnLockPackage(ctx, a.cfg, resolver, reader.Location, p.Name, p.Version, p.Resolved, p.Integrity, p.Dependencies))
|
|
}
|
|
|
|
pkg.Sort(packages)
|
|
|
|
return packages, dependency.Resolve(yarnLockDependencySpecifier, packages), unknown.IfEmptyf(packages, "unable to determine packages")
|
|
}
|
|
|
|
func findPackageName(line string) string {
|
|
if matches := packageNameExp.FindStringSubmatch(line); len(matches) >= 2 {
|
|
return matches[1]
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func findResolvedPackageAndVersion(line string) (string, string, string) {
|
|
var resolved string
|
|
if matches := resolvedExp.FindStringSubmatch(line); len(matches) >= 2 {
|
|
resolved = matches[1]
|
|
}
|
|
if matches := packageURLExp.FindStringSubmatch(line); len(matches) >= 2 {
|
|
return matches[1], matches[2], resolved
|
|
}
|
|
|
|
return "", "", ""
|
|
}
|