mirror of
https://github.com/anchore/syft.git
synced 2026-02-12 02:26:42 +01:00
* clean up .NET runtime packages Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add runtime relationships Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * remove runtime references from binary package name Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
388 lines
12 KiB
Go
388 lines
12 KiB
Go
package dotnet
|
|
|
|
import (
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/anchore/go-version"
|
|
"github.com/anchore/packageurl-go"
|
|
"github.com/anchore/syft/internal/log"
|
|
"github.com/anchore/syft/syft/cpe"
|
|
"github.com/anchore/syft/syft/file"
|
|
"github.com/anchore/syft/syft/pkg"
|
|
)
|
|
|
|
var (
|
|
// spaceRegex includes nbsp (#160) considered to be a space character
|
|
spaceRegex = regexp.MustCompile(`[\s\xa0]+`)
|
|
numberRegex = regexp.MustCompile(`\d`)
|
|
versionPunctuationRegex = regexp.MustCompile(`[.,]+`)
|
|
)
|
|
|
|
// newDotnetDepsPackage creates a new Dotnet dependency package from a logicalDepsJSONPackage.
|
|
// Note that the new logicalDepsJSONPackage now directly holds library and executable information.
|
|
func newDotnetDepsPackage(lp logicalDepsJSONPackage, depsLocation file.Location) *pkg.Package {
|
|
name, ver := extractNameAndVersion(lp.NameVersion)
|
|
locs := file.NewLocationSet(depsLocation.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
|
|
|
|
for _, pe := range lp.Executables {
|
|
locs.Add(pe.Location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.SupportingEvidenceAnnotation))
|
|
}
|
|
|
|
m := newDotnetDepsEntry(lp)
|
|
|
|
var cpes []cpe.CPE
|
|
if isRuntime(name) {
|
|
cpes = runtimeCPEs(ver)
|
|
}
|
|
|
|
p := &pkg.Package{
|
|
Name: name,
|
|
Version: ver,
|
|
Locations: locs,
|
|
PURL: packageURL(m),
|
|
Language: pkg.Dotnet,
|
|
Type: pkg.DotnetPkg,
|
|
CPEs: cpes,
|
|
Metadata: m,
|
|
}
|
|
|
|
p.SetID()
|
|
|
|
return p
|
|
}
|
|
|
|
func isRuntime(name string) bool {
|
|
// found in a self-contained net8 app in the deps.json for the application
|
|
selfContainedRuntimeDependency := strings.HasPrefix(name, "runtimepack.Microsoft.NETCore.App.Runtime")
|
|
// found in net8 apps in the deps.json for the runtime
|
|
explicitRuntimeDependency := strings.HasPrefix(name, "Microsoft.NETCore.App.Runtime")
|
|
// found in net2 apps in the deps.json for the runtime
|
|
producesARuntime := strings.HasPrefix(name, "runtime") && strings.HasSuffix(name, "Microsoft.NETCore.App")
|
|
return selfContainedRuntimeDependency || explicitRuntimeDependency || producesARuntime
|
|
}
|
|
|
|
func runtimeCPEs(ver string) []cpe.CPE {
|
|
// .NET Core Versions
|
|
// 2016: .NET Core 1.0, cpe:2.3:a:microsoft:dotnet_core:1.0:*:*:*:*:*:*:*
|
|
// 2016: .NET Core 1.1, cpe:2.3:a:microsoft:dotnet_core:1.1:*:*:*:*:*:*:*
|
|
// 2017: .NET Core 2.0, cpe:2.3:a:microsoft:dotnet_core:2.0:*:*:*:*:*:*:*
|
|
// 2018: .NET Core 2.1, cpe:2.3:a:microsoft:dotnet_core:2.1:*:*:*:*:*:*:*
|
|
// 2018: .NET Core 2.2, cpe:2.3:a:microsoft:dotnet_core:2.2:*:*:*:*:*:*:*
|
|
// 2019: .NET Core 3.0, cpe:2.3:a:microsoft:dotnet_core:3.0:*:*:*:*:*:*:*
|
|
// 2019: .NET Core 3.1, cpe:2.3:a:microsoft:dotnet_core:3.1:*:*:*:*:*:*:*
|
|
|
|
// Unified .NET Versions
|
|
// 2020: .NET 5.0, cpe:2.3:a:microsoft:dotnet:5.0:*:*:*:*:*:*:*
|
|
// 2021: .NET 6.0, cpe:2.3:a:microsoft:dotnet:6.0:*:*:*:*:*:*:*
|
|
// 2022: .NET 7.0, cpe:2.3:a:microsoft:dotnet:7.0:*:*:*:*:*:*:*
|
|
// 2023: .NET 8.0, cpe:2.3:a:microsoft:dotnet:8.0:*:*:*:*:*:*:*
|
|
// 2024: .NET 9.0, cpe:2.3:a:microsoft:dotnet:9.0:*:*:*:*:*:*:*
|
|
// 2025 ...?
|
|
|
|
fields := strings.Split(ver, ".")
|
|
majorVersion, err := strconv.Atoi(fields[0])
|
|
if err != nil {
|
|
log.WithFields("error", err).Tracef("failed to parse .NET major version from %q", ver)
|
|
return nil
|
|
}
|
|
|
|
var minorVersion int
|
|
if len(fields) > 1 {
|
|
minorVersion, err = strconv.Atoi(fields[1])
|
|
if err != nil {
|
|
log.WithFields("error", err).Tracef("failed to parse .NET minor version from %q", ver)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
productName := "dotnet"
|
|
if majorVersion < 5 {
|
|
productName = "dotnet_core"
|
|
}
|
|
|
|
return []cpe.CPE{
|
|
{
|
|
Attributes: cpe.Attributes{
|
|
Part: "a",
|
|
Vendor: "microsoft",
|
|
Product: productName,
|
|
Version: fmt.Sprintf("%d.%d", majorVersion, minorVersion),
|
|
},
|
|
// 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
|
|
Source: cpe.DeclaredSource,
|
|
},
|
|
}
|
|
}
|
|
|
|
// newDotnetDepsEntry creates a Dotnet dependency entry using the new logicalDepsJSONPackage.
|
|
func newDotnetDepsEntry(lp logicalDepsJSONPackage) pkg.DotnetDepsEntry {
|
|
name, ver := extractNameAndVersion(lp.NameVersion)
|
|
|
|
// since this is a metadata type, we should not allocate this collection unless there are entries; otherwise
|
|
// the JSON serialization will produce an empty object instead of omitting the field.
|
|
var pes map[string]pkg.DotnetPortableExecutableEntry
|
|
if len(lp.Executables) > 0 {
|
|
pes = make(map[string]pkg.DotnetPortableExecutableEntry)
|
|
for _, pe := range lp.Executables {
|
|
pes[pe.TargetPath] = newDotnetPortableExecutableEntry(pe)
|
|
}
|
|
}
|
|
|
|
var path, sha, hashPath string
|
|
lib := lp.Library
|
|
if lib != nil {
|
|
path = lib.Path
|
|
sha = lib.Sha512
|
|
hashPath = lib.HashPath
|
|
}
|
|
|
|
return pkg.DotnetDepsEntry{
|
|
Name: name,
|
|
Version: ver,
|
|
Path: path,
|
|
Sha512: sha,
|
|
HashPath: hashPath,
|
|
Executables: pes,
|
|
}
|
|
}
|
|
|
|
// newDotnetPortableExecutableEntry creates a portable executable entry from a logicalPE.
|
|
func newDotnetPortableExecutableEntry(pe logicalPE) pkg.DotnetPortableExecutableEntry {
|
|
return newDotnetPortableExecutableEntryFromMap(pe.VersionResources)
|
|
}
|
|
|
|
func newDotnetPortableExecutableEntryFromMap(vr map[string]string) pkg.DotnetPortableExecutableEntry {
|
|
return pkg.DotnetPortableExecutableEntry{
|
|
// for some reason, the assembly version is sometimes stored as "Assembly Version" and sometimes as "AssemblyVersion"
|
|
AssemblyVersion: cleanVersionResourceField(vr["Assembly Version"], vr["AssemblyVersion"]),
|
|
LegalCopyright: cleanVersionResourceField(vr["LegalCopyright"]),
|
|
Comments: cleanVersionResourceField(vr["Comments"]),
|
|
InternalName: cleanVersionResourceField(vr["InternalName"]),
|
|
CompanyName: cleanVersionResourceField(vr["CompanyName"]),
|
|
ProductName: cleanVersionResourceField(vr["ProductName"]),
|
|
ProductVersion: cleanVersionResourceField(vr["ProductVersion"]),
|
|
}
|
|
}
|
|
|
|
func cleanVersionResourceField(values ...string) string {
|
|
for _, value := range values {
|
|
if value == "" {
|
|
continue
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getDepsJSONFilePrefix(p string) string {
|
|
r := regexp.MustCompile(`([^\\\/]+)\.deps\.json$`)
|
|
match := r.FindStringSubmatch(p)
|
|
if len(match) > 1 {
|
|
return match[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractNameAndVersion(nameVersion string) (name, version string) {
|
|
fields := strings.Split(nameVersion, "/")
|
|
name = fields[0]
|
|
if len(fields) > 1 {
|
|
version = fields[1]
|
|
}
|
|
return
|
|
}
|
|
|
|
func createNameAndVersion(name, version string) (nameVersion string) {
|
|
nameVersion = fmt.Sprintf("%s/%s", name, version)
|
|
return
|
|
}
|
|
|
|
func packageURL(m pkg.DotnetDepsEntry) string {
|
|
var qualifiers packageurl.Qualifiers
|
|
|
|
return packageurl.NewPackageURL(
|
|
// Although we use TypeNuget here due to historical reasons, note that it does not necessarily
|
|
// mean the package is a NuGet package.
|
|
packageurl.TypeNuget,
|
|
"",
|
|
m.Name,
|
|
m.Version,
|
|
qualifiers,
|
|
"",
|
|
).ToString()
|
|
}
|
|
|
|
func newDotnetBinaryPackage(versionResources map[string]string, f file.Location) pkg.Package {
|
|
// TODO: we may decide to use the runtime information in the metadata, but that is not captured today
|
|
name, _ := findNameAndRuntimeFromVersionResources(versionResources)
|
|
|
|
if name == "" {
|
|
// older .NET runtime dlls may not have any version resources
|
|
name = strings.TrimSuffix(strings.TrimSuffix(path.Base(f.RealPath), ".exe"), ".dll")
|
|
}
|
|
|
|
ver := findVersionFromVersionResources(versionResources)
|
|
|
|
metadata := newDotnetPortableExecutableEntryFromMap(versionResources)
|
|
|
|
p := pkg.Package{
|
|
Name: name,
|
|
Version: ver,
|
|
Locations: file.NewLocationSet(f.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
|
|
Type: pkg.DotnetPkg,
|
|
Language: pkg.Dotnet,
|
|
PURL: binaryPackageURL(name, ver),
|
|
Metadata: metadata,
|
|
}
|
|
|
|
p.SetID()
|
|
|
|
return p
|
|
}
|
|
|
|
func binaryPackageURL(name, version string) string {
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
return packageurl.NewPackageURL(
|
|
packageurl.TypeNuget,
|
|
"",
|
|
name,
|
|
version,
|
|
nil,
|
|
"",
|
|
).ToString()
|
|
}
|
|
|
|
var binRuntimeSuffixPattern = regexp.MustCompile(`\s*\((?P<runtime>net[^)]*[0-9]+(\.[0-9]+)?)\)$`)
|
|
|
|
func findNameAndRuntimeFromVersionResources(versionResources map[string]string) (string, string) {
|
|
// PE files not authored by Microsoft tend to use ProductName as an identifier.
|
|
nameFields := []string{"ProductName", "FileDescription", "InternalName", "OriginalFilename"}
|
|
|
|
if isMicrosoftVersionResource(versionResources) {
|
|
// for Microsoft files, prioritize FileDescription.
|
|
nameFields = []string{"FileDescription", "InternalName", "OriginalFilename", "ProductName"}
|
|
}
|
|
|
|
var name string
|
|
for _, field := range nameFields {
|
|
value := spaceNormalize(versionResources[field])
|
|
if value == "" {
|
|
continue
|
|
}
|
|
name = value
|
|
break
|
|
}
|
|
|
|
var runtime string
|
|
// look for indications of the runtime, such as "(net8.0)" or "(netstandard2.2)" suffixes
|
|
runtimes := binRuntimeSuffixPattern.FindStringSubmatch(name)
|
|
if len(runtimes) > 1 {
|
|
runtime = strings.TrimSpace(runtimes[1])
|
|
name = strings.TrimSpace(strings.TrimSuffix(name, runtimes[0]))
|
|
}
|
|
|
|
return name, runtime
|
|
}
|
|
func isMicrosoftVersionResource(versionResources map[string]string) bool {
|
|
return strings.Contains(strings.ToLower(versionResources["CompanyName"]), "microsoft") ||
|
|
strings.Contains(strings.ToLower(versionResources["ProductName"]), "microsoft")
|
|
}
|
|
|
|
// spaceNormalize trims and normalizes whitespace in a string.
|
|
func spaceNormalize(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
// Ensure valid UTF-8.
|
|
value = strings.ToValidUTF8(value, "")
|
|
// Consolidate all whitespace.
|
|
value = spaceRegex.ReplaceAllString(value, " ")
|
|
// Remove non-printable characters.
|
|
value = regexp.MustCompile(`[\x00-\x1f]`).ReplaceAllString(value, "")
|
|
// Consolidate again and trim.
|
|
value = spaceRegex.ReplaceAllString(value, " ")
|
|
value = strings.TrimSpace(value)
|
|
return value
|
|
}
|
|
|
|
func findVersionFromVersionResources(versionResources map[string]string) string {
|
|
productVersion := extractVersionFromResourcesValue(versionResources["ProductVersion"])
|
|
fileVersion := extractVersionFromResourcesValue(versionResources["FileVersion"])
|
|
|
|
semanticVersionCompareResult := keepGreaterSemanticVersion(productVersion, fileVersion)
|
|
if semanticVersionCompareResult != "" {
|
|
return semanticVersionCompareResult
|
|
}
|
|
|
|
productVersionDetail := punctuationCount(productVersion)
|
|
fileVersionDetail := punctuationCount(fileVersion)
|
|
|
|
if containsNumber(productVersion) && productVersionDetail >= fileVersionDetail {
|
|
return productVersion
|
|
}
|
|
if containsNumber(fileVersion) && fileVersionDetail > 0 {
|
|
return fileVersion
|
|
}
|
|
if containsNumber(productVersion) {
|
|
return productVersion
|
|
}
|
|
if containsNumber(fileVersion) {
|
|
return fileVersion
|
|
}
|
|
|
|
return productVersion
|
|
}
|
|
|
|
func extractVersionFromResourcesValue(version string) string {
|
|
version = strings.TrimSpace(version)
|
|
out := ""
|
|
for i, f := range strings.Fields(version) {
|
|
if containsNumber(out) && !containsNumber(f) {
|
|
return out
|
|
}
|
|
if i == 0 {
|
|
out = f
|
|
} else {
|
|
out += " " + f
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func keepGreaterSemanticVersion(productVersion string, fileVersion string) string {
|
|
semanticProductVersion, err := version.NewVersion(productVersion)
|
|
if err != nil || semanticProductVersion == nil {
|
|
log.Tracef("Unable to create semantic version from product version %s", productVersion)
|
|
return ""
|
|
}
|
|
|
|
semanticFileVersion, err := version.NewVersion(fileVersion)
|
|
if err != nil || semanticFileVersion == nil {
|
|
log.Tracef("Unable to create semantic version from file version %s", fileVersion)
|
|
return productVersion
|
|
}
|
|
|
|
if semanticProductVersion.Equal(semanticFileVersion) {
|
|
return ""
|
|
}
|
|
if semanticFileVersion.GreaterThan(semanticProductVersion) {
|
|
return fileVersion
|
|
}
|
|
return productVersion
|
|
}
|
|
|
|
func containsNumber(s string) bool {
|
|
return numberRegex.MatchString(s)
|
|
}
|
|
|
|
func punctuationCount(s string) int {
|
|
return len(versionPunctuationRegex.FindAllString(s, -1))
|
|
}
|