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

     1  package dotnet
     2  
     3  import (
     4  	"fmt"
     5  	"path"
     6  	"regexp"
     7  	"strconv"
     8  	"strings"
     9  
    10  	"github.com/anchore/go-version"
    11  	"github.com/anchore/packageurl-go"
    12  	"github.com/anchore/syft/internal/log"
    13  	"github.com/anchore/syft/syft/cpe"
    14  	"github.com/anchore/syft/syft/file"
    15  	"github.com/anchore/syft/syft/pkg"
    16  )
    17  
    18  var (
    19  	// spaceRegex includes nbsp (#160) considered to be a space character
    20  	spaceRegex              = regexp.MustCompile(`[\s\xa0]+`)
    21  	numberRegex             = regexp.MustCompile(`\d`)
    22  	versionPunctuationRegex = regexp.MustCompile(`[.,]+`)
    23  )
    24  
    25  // newDotnetDepsPackage creates a new Dotnet dependency package from a logicalDepsJSONPackage.
    26  // Note that the new logicalDepsJSONPackage now directly holds library and executable information.
    27  func newDotnetDepsPackage(lp logicalDepsJSONPackage, depsLocation file.Location) *pkg.Package {
    28  	name, ver := extractNameAndVersion(lp.NameVersion)
    29  	locs := file.NewLocationSet(depsLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
    30  
    31  	for _, pe := range lp.Executables {
    32  		locs.Add(pe.Location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.SupportingEvidenceAnnotation))
    33  	}
    34  
    35  	m := newDotnetDepsEntry(lp)
    36  
    37  	var cpes []cpe.CPE
    38  	if isRuntime(name) {
    39  		cpes = runtimeCPEs(ver)
    40  	}
    41  
    42  	p := &pkg.Package{
    43  		Name:      name,
    44  		Version:   ver,
    45  		Locations: locs,
    46  		PURL:      packageURL(m),
    47  		Language:  pkg.Dotnet,
    48  		Type:      pkg.DotnetPkg,
    49  		CPEs:      cpes,
    50  		Metadata:  m,
    51  	}
    52  
    53  	p.SetID()
    54  
    55  	return p
    56  }
    57  
    58  func isRuntime(name string) bool {
    59  	// found in a self-contained net8 app in the deps.json for the application
    60  	selfContainedRuntimeDependency := strings.HasPrefix(name, "runtimepack.Microsoft.NETCore.App.Runtime")
    61  	// found in net8 apps in the deps.json for the runtime
    62  	explicitRuntimeDependency := strings.HasPrefix(name, "Microsoft.NETCore.App.Runtime")
    63  	// found in net2 apps in the deps.json for the runtime
    64  	producesARuntime := strings.HasPrefix(name, "runtime") && strings.HasSuffix(name, "Microsoft.NETCore.App")
    65  	return selfContainedRuntimeDependency || explicitRuntimeDependency || producesARuntime
    66  }
    67  
    68  func runtimeCPEs(ver string) []cpe.CPE {
    69  	// .NET Core Versions
    70  	// 2016: .NET Core 1.0, cpe:2.3:a:microsoft:dotnet_core:1.0:*:*:*:*:*:*:*
    71  	// 2016: .NET Core 1.1, cpe:2.3:a:microsoft:dotnet_core:1.1:*:*:*:*:*:*:*
    72  	// 2017: .NET Core 2.0, cpe:2.3:a:microsoft:dotnet_core:2.0:*:*:*:*:*:*:*
    73  	// 2018: .NET Core 2.1, cpe:2.3:a:microsoft:dotnet_core:2.1:*:*:*:*:*:*:*
    74  	// 2018: .NET Core 2.2, cpe:2.3:a:microsoft:dotnet_core:2.2:*:*:*:*:*:*:*
    75  	// 2019: .NET Core 3.0, cpe:2.3:a:microsoft:dotnet_core:3.0:*:*:*:*:*:*:*
    76  	// 2019: .NET Core 3.1, cpe:2.3:a:microsoft:dotnet_core:3.1:*:*:*:*:*:*:*
    77  
    78  	// Unified .NET Versions
    79  	// 2020: .NET 5.0, cpe:2.3:a:microsoft:dotnet:5.0:*:*:*:*:*:*:*
    80  	// 2021: .NET 6.0, cpe:2.3:a:microsoft:dotnet:6.0:*:*:*:*:*:*:*
    81  	// 2022: .NET 7.0, cpe:2.3:a:microsoft:dotnet:7.0:*:*:*:*:*:*:*
    82  	// 2023: .NET 8.0, cpe:2.3:a:microsoft:dotnet:8.0:*:*:*:*:*:*:*
    83  	// 2024: .NET 9.0, cpe:2.3:a:microsoft:dotnet:9.0:*:*:*:*:*:*:*
    84  	// 2025 ...?
    85  
    86  	fields := strings.Split(ver, ".")
    87  	majorVersion, err := strconv.Atoi(fields[0])
    88  	if err != nil {
    89  		log.WithFields("error", err).Tracef("failed to parse .NET major version from %q", ver)
    90  		return nil
    91  	}
    92  
    93  	var minorVersion int
    94  	if len(fields) > 1 {
    95  		minorVersion, err = strconv.Atoi(fields[1])
    96  		if err != nil {
    97  			log.WithFields("error", err).Tracef("failed to parse .NET minor version from %q", ver)
    98  			return nil
    99  		}
   100  	}
   101  
   102  	productName := "dotnet"
   103  	if majorVersion < 5 {
   104  		productName = "dotnet_core"
   105  	}
   106  
   107  	return []cpe.CPE{
   108  		{
   109  			Attributes: cpe.Attributes{
   110  				Part:    "a",
   111  				Vendor:  "microsoft",
   112  				Product: productName,
   113  				Version: fmt.Sprintf("%d.%d", majorVersion, minorVersion),
   114  			},
   115  			// we didn't find this in the underlying material, but this is the convention in NVD and we are certain this is a runtime package
   116  			Source: cpe.DeclaredSource,
   117  		},
   118  	}
   119  }
   120  
   121  // newDotnetDepsEntry creates a Dotnet dependency entry using the new logicalDepsJSONPackage.
   122  func newDotnetDepsEntry(lp logicalDepsJSONPackage) pkg.DotnetDepsEntry {
   123  	name, ver := extractNameAndVersion(lp.NameVersion)
   124  
   125  	// since this is a metadata type, we should not allocate this collection unless there are entries; otherwise
   126  	// the JSON serialization will produce an empty object instead of omitting the field.
   127  	var pes map[string]pkg.DotnetPortableExecutableEntry
   128  	if len(lp.Executables) > 0 {
   129  		pes = make(map[string]pkg.DotnetPortableExecutableEntry)
   130  		for _, pe := range lp.Executables {
   131  			pes[pe.TargetPath] = newDotnetPortableExecutableEntry(pe)
   132  		}
   133  	}
   134  
   135  	var path, sha, hashPath string
   136  	lib := lp.Library
   137  	if lib != nil {
   138  		path = lib.Path
   139  		sha = lib.Sha512
   140  		hashPath = lib.HashPath
   141  	}
   142  
   143  	return pkg.DotnetDepsEntry{
   144  		Name:        name,
   145  		Version:     ver,
   146  		Path:        path,
   147  		Sha512:      sha,
   148  		HashPath:    hashPath,
   149  		Executables: pes,
   150  	}
   151  }
   152  
   153  // newDotnetPortableExecutableEntry creates a portable executable entry from a File.
   154  func newDotnetPortableExecutableEntry(pe logicalPE) pkg.DotnetPortableExecutableEntry {
   155  	return newDotnetPortableExecutableEntryFromMap(pe.VersionResources)
   156  }
   157  
   158  func newDotnetPortableExecutableEntryFromMap(vr map[string]string) pkg.DotnetPortableExecutableEntry {
   159  	return pkg.DotnetPortableExecutableEntry{
   160  		// for some reason, the assembly version is sometimes stored as "Assembly Version" and sometimes as "AssemblyVersion"
   161  		AssemblyVersion: cleanVersionResourceField(vr["Assembly Version"], vr["AssemblyVersion"]),
   162  		LegalCopyright:  cleanVersionResourceField(vr["LegalCopyright"]),
   163  		Comments:        cleanVersionResourceField(vr["Comments"]),
   164  		InternalName:    cleanVersionResourceField(vr["InternalName"]),
   165  		CompanyName:     cleanVersionResourceField(vr["CompanyName"]),
   166  		ProductName:     cleanVersionResourceField(vr["ProductName"]),
   167  		ProductVersion:  cleanVersionResourceField(vr["ProductVersion"]),
   168  	}
   169  }
   170  
   171  func cleanVersionResourceField(values ...string) string {
   172  	for _, value := range values {
   173  		if value == "" {
   174  			continue
   175  		}
   176  		return strings.TrimSpace(value)
   177  	}
   178  	return ""
   179  }
   180  
   181  func getDepsJSONFilePrefix(p string) string {
   182  	r := regexp.MustCompile(`([^\\\/]+)\.deps\.json$`)
   183  	match := r.FindStringSubmatch(p)
   184  	if len(match) > 1 {
   185  		return match[1]
   186  	}
   187  	return ""
   188  }
   189  
   190  func extractNameAndVersion(nameVersion string) (name, version string) {
   191  	fields := strings.Split(nameVersion, "/")
   192  	name = fields[0]
   193  	if len(fields) > 1 {
   194  		version = fields[1]
   195  	}
   196  	return
   197  }
   198  
   199  func createNameAndVersion(name, version string) string {
   200  	return fmt.Sprintf("%s/%s", name, version)
   201  }
   202  
   203  func packageURL(m pkg.DotnetDepsEntry) string {
   204  	var qualifiers packageurl.Qualifiers
   205  
   206  	return packageurl.NewPackageURL(
   207  		// Although we use TypeNuget here due to historical reasons, note that it does not necessarily
   208  		// mean the package is a NuGet package.
   209  		packageurl.TypeNuget,
   210  		"",
   211  		m.Name,
   212  		m.Version,
   213  		qualifiers,
   214  		"",
   215  	).ToString()
   216  }
   217  
   218  func newDotnetBinaryPackage(versionResources map[string]string, f file.Location) pkg.Package {
   219  	// TODO: we may decide to use the runtime information in the metadata, but that is not captured today
   220  	name, _ := findNameAndRuntimeFromVersionResources(versionResources)
   221  
   222  	if name == "" {
   223  		// older .NET runtime dlls may not have any version resources
   224  		name = strings.TrimSuffix(strings.TrimSuffix(path.Base(f.RealPath), ".exe"), ".dll")
   225  	}
   226  
   227  	ver := findVersionFromVersionResources(versionResources)
   228  
   229  	metadata := newDotnetPortableExecutableEntryFromMap(versionResources)
   230  
   231  	p := pkg.Package{
   232  		Name:      name,
   233  		Version:   ver,
   234  		Locations: file.NewLocationSet(f.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
   235  		Type:      pkg.DotnetPkg,
   236  		Language:  pkg.Dotnet,
   237  		PURL:      binaryPackageURL(name, ver),
   238  		Metadata:  metadata,
   239  	}
   240  
   241  	p.SetID()
   242  
   243  	return p
   244  }
   245  
   246  func binaryPackageURL(name, version string) string {
   247  	if name == "" {
   248  		return ""
   249  	}
   250  	return packageurl.NewPackageURL(
   251  		packageurl.TypeNuget,
   252  		"",
   253  		name,
   254  		version,
   255  		nil,
   256  		"",
   257  	).ToString()
   258  }
   259  
   260  var binRuntimeSuffixPattern = regexp.MustCompile(`\s*\((?P<runtime>net[^)]*[0-9]+(\.[0-9]+)?)\)$`)
   261  
   262  func findNameAndRuntimeFromVersionResources(versionResources map[string]string) (string, string) {
   263  	// PE files not authored by Microsoft tend to use ProductName as an identifier.
   264  	nameFields := []string{"ProductName", "FileDescription", "InternalName", "OriginalFilename"}
   265  
   266  	if isMicrosoftVersionResource(versionResources) {
   267  		// for Microsoft files, prioritize FileDescription.
   268  		nameFields = []string{"FileDescription", "InternalName", "OriginalFilename", "ProductName"}
   269  	}
   270  
   271  	var name string
   272  	for _, field := range nameFields {
   273  		value := spaceNormalize(versionResources[field])
   274  		if value == "" {
   275  			continue
   276  		}
   277  		name = value
   278  		break
   279  	}
   280  
   281  	var runtime string
   282  	// look for indications of the runtime, such as "(net8.0)" or "(netstandard2.2)" suffixes
   283  	runtimes := binRuntimeSuffixPattern.FindStringSubmatch(name)
   284  	if len(runtimes) > 1 {
   285  		runtime = strings.TrimSpace(runtimes[1])
   286  		name = strings.TrimSpace(strings.TrimSuffix(name, runtimes[0]))
   287  	}
   288  
   289  	return name, runtime
   290  }
   291  func isMicrosoftVersionResource(versionResources map[string]string) bool {
   292  	return strings.Contains(strings.ToLower(versionResources["CompanyName"]), "microsoft") ||
   293  		strings.Contains(strings.ToLower(versionResources["ProductName"]), "microsoft")
   294  }
   295  
   296  // spaceNormalize trims and normalizes whitespace in a string.
   297  func spaceNormalize(value string) string {
   298  	value = strings.TrimSpace(value)
   299  	if value == "" {
   300  		return ""
   301  	}
   302  	// Ensure valid UTF-8.
   303  	value = strings.ToValidUTF8(value, "")
   304  	// Consolidate all whitespace.
   305  	value = spaceRegex.ReplaceAllString(value, " ")
   306  	// Remove non-printable characters.
   307  	value = regexp.MustCompile(`[\x00-\x1f]`).ReplaceAllString(value, "")
   308  	// Consolidate again and trim.
   309  	value = spaceRegex.ReplaceAllString(value, " ")
   310  	value = strings.TrimSpace(value)
   311  	return value
   312  }
   313  
   314  func findVersionFromVersionResources(versionResources map[string]string) string {
   315  	productVersion := extractVersionFromResourcesValue(versionResources["ProductVersion"])
   316  	fileVersion := extractVersionFromResourcesValue(versionResources["FileVersion"])
   317  
   318  	semanticVersionCompareResult := keepGreaterSemanticVersion(productVersion, fileVersion)
   319  	if semanticVersionCompareResult != "" {
   320  		return semanticVersionCompareResult
   321  	}
   322  
   323  	productVersionDetail := punctuationCount(productVersion)
   324  	fileVersionDetail := punctuationCount(fileVersion)
   325  
   326  	if containsNumber(productVersion) && productVersionDetail >= fileVersionDetail {
   327  		return productVersion
   328  	}
   329  	if containsNumber(fileVersion) && fileVersionDetail > 0 {
   330  		return fileVersion
   331  	}
   332  	if containsNumber(productVersion) {
   333  		return productVersion
   334  	}
   335  	if containsNumber(fileVersion) {
   336  		return fileVersion
   337  	}
   338  
   339  	return productVersion
   340  }
   341  
   342  func extractVersionFromResourcesValue(version string) string {
   343  	version = strings.TrimSpace(version)
   344  	out := ""
   345  	for i, f := range strings.Fields(version) {
   346  		if containsNumber(out) && !containsNumber(f) {
   347  			return out
   348  		}
   349  		if i == 0 {
   350  			out = f
   351  		} else {
   352  			out += " " + f
   353  		}
   354  	}
   355  	return out
   356  }
   357  
   358  func keepGreaterSemanticVersion(productVersion string, fileVersion string) string {
   359  	semanticProductVersion, err := version.NewVersion(productVersion)
   360  	if err != nil || semanticProductVersion == nil {
   361  		log.Tracef("Unable to create semantic version from product version %s", productVersion)
   362  		return ""
   363  	}
   364  
   365  	semanticFileVersion, err := version.NewVersion(fileVersion)
   366  	if err != nil || semanticFileVersion == nil {
   367  		log.Tracef("Unable to create semantic version from file version %s", fileVersion)
   368  		return productVersion
   369  	}
   370  
   371  	if semanticProductVersion.Equal(semanticFileVersion) {
   372  		return ""
   373  	}
   374  	if semanticFileVersion.GreaterThan(semanticProductVersion) {
   375  		return fileVersion
   376  	}
   377  	return productVersion
   378  }
   379  
   380  func containsNumber(s string) bool {
   381  	return numberRegex.MatchString(s)
   382  }
   383  
   384  func punctuationCount(s string) int {
   385  	return len(versionPunctuationRegex.FindAllString(s, -1))
   386  }