From 65ade9d4bc083eef6426fdee823441f07a950c7b Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Fri, 3 Jan 2025 14:34:49 -0500 Subject: [PATCH 1/2] [wip] prototype PE binary parser Signed-off-by: Alex Goodman --- go.mod | 3 - go.sum | 17 - syft/pkg/cataloger/dotnet/package.go | 194 ++++++ .../parse_dotnet_portable_executable.go | 622 +++++++++++++----- .../parse_dotnet_portable_executable_test.go | 6 +- 5 files changed, 668 insertions(+), 174 deletions(-) diff --git a/go.mod b/go.mod index 60532119170..aa0839d3dd1 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,6 @@ require ( github.com/opencontainers/go-digest v1.0.0 github.com/pelletier/go-toml v1.9.5 github.com/quasilyte/go-ruleguard/dsl v0.3.22 - github.com/saferwall/pe v1.5.6 github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d github.com/sanity-io/litter v1.5.5 github.com/sassoftware/go-rpmutils v0.4.0 @@ -134,7 +133,6 @@ require ( github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect - github.com/edsrzf/mmap-go v1.1.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/fgprof v0.9.3 // indirect @@ -202,7 +200,6 @@ require ( github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.0 // indirect diff --git a/go.sum b/go.sum index a5900a661ce..27a05ee0e63 100644 --- a/go.sum +++ b/go.sum @@ -247,8 +247,6 @@ github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj6 github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= -github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= github.com/elliotchance/phpserialize v1.4.0 h1:cAp/9+KSnEbUC8oYCE32n2n84BeW8HOY3HMDI8hG2OY= @@ -676,8 +674,6 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/saferwall/pe v1.5.6 h1:DrRLnoQFxHWJ5lJUmrH7X2L0xeUu6SUS95Dc61eW2Yc= -github.com/saferwall/pe v1.5.6/go.mod h1:mJx+PuptmNpoPFBNhWs/uDMFL/kTHVZIkg0d4OUJFbQ= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= @@ -696,8 +692,6 @@ github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd7 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie/v2 v2.5.5 h1:rx1mwF95RxZ3/83sdS4Yp7t2C5TCokvWP4TBRbAyEWY= github.com/sebdah/goldie/v2 v2.5.5/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= -github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d h1:RQqyEogx5J6wPdoxqL132b100j8KjcVHO1c0KLRoIhc= -github.com/secDre4mer/pkcs7 v0.0.0-20240322103146-665324a4461d/go.mod h1:PegD7EVqlN88z7TpCqH92hHP+GBpfomGCCnw1PFtNOA= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= @@ -813,7 +807,6 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1 h1:V+UsotZpAVvfj3X/LMoEytoLzSiP6Lg0F7wdVyu9gGg= github.com/zyedidia/generic v1.2.2-0.20230320175451-4410d2372cb1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= @@ -864,7 +857,6 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= @@ -906,7 +898,6 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -951,7 +942,6 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -982,7 +972,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1053,17 +1042,13 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1075,7 +1060,6 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1137,7 +1121,6 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/syft/pkg/cataloger/dotnet/package.go b/syft/pkg/cataloger/dotnet/package.go index 0d1563cd7df..dcbebdcefc7 100644 --- a/syft/pkg/cataloger/dotnet/package.go +++ b/syft/pkg/cataloger/dotnet/package.go @@ -5,11 +5,20 @@ import ( "regexp" "strings" + "github.com/anchore/go-version" "github.com/anchore/packageurl-go" + "github.com/anchore/syft/internal/log" "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(`[.,]+`) +) + func newDotnetDepsPackage(nameVersion string, lib dotnetDepsLibrary, locations ...file.Location) *pkg.Package { name, version := extractNameAndVersion(nameVersion) @@ -79,3 +88,188 @@ func packageURL(m pkg.DotnetDepsEntry) string { "", ).ToString() } + +func newDotnetBinaryPackage(versionResources map[string]string, f file.LocationReadCloser) (dnpkg pkg.Package, err error) { + name := findNameFromVersionResources(versionResources) + if name == "" { + return dnpkg, fmt.Errorf("unable to find PE name in file") + } + + version := findVersionFromVersionResources(versionResources) + if version == "" { + return dnpkg, fmt.Errorf("unable to find PE version in file") + } + + metadata := pkg.DotnetPortableExecutableEntry{ + AssemblyVersion: versionResources["Assembly Version"], + LegalCopyright: versionResources["LegalCopyright"], + Comments: versionResources["Comments"], + InternalName: versionResources["InternalName"], + CompanyName: versionResources["CompanyName"], + ProductName: versionResources["ProductName"], + ProductVersion: versionResources["ProductVersion"], + } + + dnpkg = pkg.Package{ + Name: name, + Version: version, + Locations: file.NewLocationSet(f.Location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)), + Type: pkg.DotnetPkg, + Language: pkg.Dotnet, + PURL: binaryPackageURL(name, version), + Metadata: metadata, + } + + dnpkg.SetID() + + return dnpkg, nil +} + +func binaryPackageURL(name, version string) string { + return packageurl.NewPackageURL( + packageurl.TypeNuget, // See explanation in syft/pkg/cataloger/dotnet/package.go as to why this was chosen. + "", + name, + version, + nil, + "", + ).ToString() +} + +func findNameFromVersionResources(versionResources map[string]string) string { + // PE files found in the wild _not_ authored by Microsoft seem to use ProductName as a clear + // identifier of the software + nameFields := []string{"ProductName", "FileDescription", "InternalName", "OriginalFilename"} + + if isMicrosoftVersionResource(versionResources) { + // Microsoft seems to be consistent using the FileDescription, with a few that are blank and have + // fallbacks to ProductName last, as this is often something very broad like "Microsoft Windows" + nameFields = []string{"FileDescription", "InternalName", "OriginalFilename", "ProductName"} + } + + for _, field := range nameFields { + value := spaceNormalize(versionResources[field]) + if value == "" { + continue + } + return value + } + + return "" +} + +func isMicrosoftVersionResource(versionResources map[string]string) bool { + return strings.Contains(strings.ToLower(versionResources["CompanyName"]), "microsoft") || + strings.Contains(strings.ToLower(versionResources["ProductName"]), "microsoft") +} + +// normalizes a string to a trimmed version with all contigous whitespace collapsed to a single space character +func spaceNormalize(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + // ensure valid utf8 text + value = strings.ToValidUTF8(value, "") + // consolidate all space characters + value = spaceRegex.ReplaceAllString(value, " ") + // remove other non-space, non-printable characters + value = regexp.MustCompile(`[\x00-\x1f]`).ReplaceAllString(value, "") + // consolidate all space characters again in case other non-printables were in-between + value = spaceRegex.ReplaceAllString(value, " ") + // finally, remove any remaining surrounding whitespace + 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 := "" + + // some example versions are: "1, 0, 0, 0", "Release 73" or "4.7.4076.0 built by: NET472REL1LAST_B" + // so try to split it and take the first parts that look numeric + for i, f := range strings.Fields(version) { + // if the output already has a number but the current segment does not have a number, + // return what we found for the 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 portable executable product version %s", productVersion) + return "" + } + + semanticFileVersion, err := version.NewVersion(fileVersion) + + if err != nil || semanticFileVersion == nil { + log.Tracef("Unable to create semantic version from portable executable file version %s", fileVersion) + return productVersion + } + + // Make no choice when they are semantically equal so that it falls + // through to the other comparison cases + 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)) +} diff --git a/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go b/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go index 4ea4d1c18d7..d909436eeab 100644 --- a/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go +++ b/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go @@ -1,50 +1,130 @@ package dotnet import ( + "bytes" "context" + "debug/pe" + "encoding/binary" + "errors" "fmt" "io" - "regexp" - "strings" - "github.com/saferwall/pe" - - version "github.com/anchore/go-version" - "github.com/anchore/packageurl-go" "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/internal/unionreader" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" ) +const peMaxAllowedDirectoryEntries = 0x1000 + var _ generic.Parser = parseDotnetPortableExecutable -func parseDotnetPortableExecutable(_ context.Context, _ file.Resolver, _ *generic.Environment, f file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { - by, err := io.ReadAll(f) - if err != nil { - return nil, nil, fmt.Errorf("unable to read file: %w", err) - } +type peDosHeader struct { + Magic [2]byte // "MZ" + Unused [58]byte + AddressOfNewEXEHeader uint32 // offset to PE header +} + +// peImageResourceDirectory represents the resource directory structure. +type peImageResourceDirectory struct { + Characteristics uint32 + TimeDateStamp uint32 + MajorVersion uint16 + MinorVersion uint16 + NumberOfNamedEntries uint16 + NumberOfIDEntries uint16 +} + +// peImageResourceDirectoryEntry represents an entry in the resource directory entries. +type peImageResourceDirectoryEntry struct { + Name uint32 + OffsetToData uint32 +} + +// peImageResourceDataEntry is the unit of raw data in the Resource Data area. +type peImageResourceDataEntry struct { + OffsetToData uint32 + Size uint32 + CodePage uint32 + Reserved uint32 +} + +// peResourceDirectory represents resource directory information. +type peResourceDirectory struct { + Struct peImageResourceDirectory + Entries []peResourceDirectoryEntry +} + +// peResourceDirectoryEntry represents a resource directory entry. +type peResourceDirectoryEntry struct { + Struct peImageResourceDirectoryEntry + Name string + ID uint32 + IsResourceDir bool + Directory peResourceDirectory + Data peResourceDataEntry +} + +// peResourceDataEntry represents a resource data entry. +type peResourceDataEntry struct { + Struct peImageResourceDataEntry + Lang uint32 + SubLang uint32 +} + +// peVsFixedFileInfo represents the fixed file information structure. +type peVsFixedFileInfo struct { + Signature uint32 + StructVersion uint32 + FileVersionMS uint32 + FileVersionLS uint32 + ProductVersionMS uint32 + ProductVersionLS uint32 + FileFlagsMask uint32 + FileFlags uint32 + FileOS uint32 + FileType uint32 + FileSubtype uint32 + FileDateMS uint32 + FileDateLS uint32 +} + +type peVsVersionInfo peLenValLenType + +type peStringFileInfo peLenValLenType + +type peStringTable peLenValLenType + +type peString peLenValLenType + +type peLenValLenType struct { + Length uint16 + ValueLength uint16 + Type uint16 +} - peFile, err := pe.NewBytes(by, &pe.Options{}) +func parseDotnetPortableExecutable(_ context.Context, _ file.Resolver, _ *generic.Environment, f file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { + r, err := unionreader.GetUnionReader(f) if err != nil { - log.Tracef("unable to create PE instance for file '%s': %v", f.RealPath, err) return nil, nil, err } - err = peFile.Parse() - if err != nil { - log.Tracef("unable to parse PE file '%s': %v", f.RealPath, err) - return nil, nil, err + vAddress, vSize, rsrcSection, err := findResourceSection(r) + if rsrcSection == nil { + return nil, nil, errors.New("resource section not found") } - versionResources, err := peFile.ParseVersionResources() + var dirs []uint32 + versionResources := make(map[string]string) + err = parseResourceDirectory(rsrcSection, vAddress, vSize, vAddress, dirs, versionResources) if err != nil { - log.Tracef("unable to parse version resources in PE file: %s: %v", f.RealPath, err) - return nil, nil, fmt.Errorf("unable to parse version resources in PE file: %w", err) + log.WithFields("error", err).Error("unable to parse version resources in PE file") + return nil, nil, err } - dotNetPkg, err := buildDotNetPackage(versionResources, f) + dotNetPkg, err := newDotnetBinaryPackage(versionResources, f) if err != nil { log.Tracef("unable to build dotnet package for: %v %v", f.RealPath, err) return nil, nil, err @@ -53,194 +133,434 @@ func parseDotnetPortableExecutable(_ context.Context, _ file.Resolver, _ *generi return []pkg.Package{dotNetPkg}, nil, nil } -func buildDotNetPackage(versionResources map[string]string, f file.LocationReadCloser) (dnpkg pkg.Package, err error) { - name := findName(versionResources) - if name == "" { - return dnpkg, fmt.Errorf("unable to find PE name in file") +// findResourceSection locates and reads the .rsrc section using debug/pe types. +func findResourceSection(file unionreader.UnionReader) (uint32, uint32, *bytes.Reader, error) { + var dosHeader peDosHeader + if err := binary.Read(file, binary.LittleEndian, &dosHeader); err != nil { + return 0, 0, nil, fmt.Errorf("error reading DOS header: %w", err) + } + if string(dosHeader.Magic[:]) != "MZ" { + return 0, 0, nil, fmt.Errorf("invalid DOS header magic") } - version := findVersion(versionResources) - if version == "" { - return dnpkg, fmt.Errorf("unable to find PE version in file") + peOffset := int64(dosHeader.AddressOfNewEXEHeader) + if _, err := file.Seek(peOffset, io.SeekStart); err != nil { + return 0, 0, nil, fmt.Errorf("error seeking to PE header: %w", err) } - metadata := pkg.DotnetPortableExecutableEntry{ - AssemblyVersion: versionResources["Assembly Version"], - LegalCopyright: versionResources["LegalCopyright"], - Comments: versionResources["Comments"], - InternalName: versionResources["InternalName"], - CompanyName: versionResources["CompanyName"], - ProductName: versionResources["ProductName"], - ProductVersion: versionResources["ProductVersion"], + var signature [4]byte + if err := binary.Read(file, binary.LittleEndian, &signature); err != nil { + return 0, 0, nil, fmt.Errorf("error reading PE signature: %w", err) + } + if !bytes.Equal(signature[:], []byte("PE\x00\x00")) { + return 0, 0, nil, fmt.Errorf("invalid PE signature") } - dnpkg = pkg.Package{ - Name: name, - Version: version, - Locations: file.NewLocationSet(f.Location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)), - Type: pkg.DotnetPkg, - Language: pkg.Dotnet, - PURL: portableExecutablePackageURL(name, version), - Metadata: metadata, + var fileHeader pe.FileHeader + if err := binary.Read(file, binary.LittleEndian, &fileHeader); err != nil { + return 0, 0, nil, fmt.Errorf("error reading file header: %w", err) } - dnpkg.SetID() + var magic uint16 + if err := binary.Read(file, binary.LittleEndian, &magic); err != nil { + return 0, 0, nil, fmt.Errorf("error reading optional header magic: %w", err) + } - return dnpkg, nil -} + // seek back to before reading magic (since that value is in the header) + if _, err := file.Seek(-2, io.SeekCurrent); err != nil { + return 0, 0, nil, fmt.Errorf("error seeking back to before reading magic: %w", err) + } -func portableExecutablePackageURL(name, version string) string { - return packageurl.NewPackageURL( - packageurl.TypeNuget, // See explanation in syft/pkg/cataloger/dotnet/package.go as to why this was chosen. - "", - name, - version, - nil, - "", - ).ToString() -} + var optVirtualAddress uint32 + var optSize uint32 + if magic == 0x10B { // PE32 + var optHeader pe.OptionalHeader32 + if err := binary.Read(file, binary.LittleEndian, &optHeader); err != nil { + return 0, 0, nil, fmt.Errorf("error reading optional header (PE32): %w", err) + } -func extractVersion(version string) string { - version = strings.TrimSpace(version) + if optHeader.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_RESOURCE].Size != 0 { + sectionHeader := optHeader.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_RESOURCE] + optVirtualAddress = sectionHeader.VirtualAddress + optSize = sectionHeader.Size + } + } else if magic == 0x20B { // PE32+ + var optHeader pe.OptionalHeader64 + if err := binary.Read(file, binary.LittleEndian, &optHeader); err != nil { + return 0, 0, nil, fmt.Errorf("error reading optional header (PE32+): %w", err) + } - out := "" + if optHeader.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_RESOURCE].Size != 0 { + sectionHeader := optHeader.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_RESOURCE] + optVirtualAddress = sectionHeader.VirtualAddress + optSize = sectionHeader.Size + } + } else { + return 0, 0, nil, fmt.Errorf("unknown optional header magic: 0x%x", magic) + } - // some example versions are: "1, 0, 0, 0", "Release 73" or "4.7.4076.0 built by: NET472REL1LAST_B" - // so try to split it and take the first parts that look numeric - for i, f := range strings.Fields(version) { - // if the output already has a number but the current segment does not have a number, - // return what we found for the version - if containsNumber(out) && !containsNumber(f) { - return out + var otherSections []pe.SectionHeader32 + for i := 0; i < int(fileHeader.NumberOfSections); i++ { + var sectionHeader pe.SectionHeader32 + if err := binary.Read(file, binary.LittleEndian, §ionHeader); err != nil { + return 0, 0, nil, fmt.Errorf("error reading section header: %w", err) } - if i == 0 { - out = f + sectionName := string(bytes.Trim(sectionHeader.Name[:], "\x00")) + if sectionName == ".rsrc" { + // seek to the raw data of the section + if _, err := file.Seek(int64(sectionHeader.PointerToRawData), io.SeekStart); err != nil { + return 0, 0, nil, fmt.Errorf("error seeking to .rsrc data: %w", err) + } + + // read the raw data + data := make([]byte, sectionHeader.SizeOfRawData) + if _, err := file.Read(data); err != nil { + return 0, 0, nil, fmt.Errorf("error reading .rsrc section data: %w", err) + } + + return sectionHeader.VirtualAddress, sectionHeader.SizeOfRawData, bytes.NewReader(data), nil } else { - out += " " + f + otherSections = append(otherSections, sectionHeader) } } - return out -} + if optVirtualAddress != 0 && optSize != 0 { + for _, section := range otherSections { + if optVirtualAddress >= section.VirtualAddress && optVirtualAddress < section.VirtualAddress+section.VirtualSize { + // seek to the raw data of the section + if _, err := file.Seek(int64(section.PointerToRawData), io.SeekStart); err != nil { + return 0, 0, nil, fmt.Errorf("error seeking to .rsrc data: %w", err) + } + + // read the raw data + data := make([]byte, section.SizeOfRawData) + if _, err := file.Read(data); err != nil { + return 0, 0, nil, fmt.Errorf("error reading .rsrc section data: %w", err) + } + + return optVirtualAddress, optSize, bytes.NewReader(data), nil + } + } + } -func keepGreaterSemanticVersion(productVersion string, fileVersion string) string { - semanticProductVersion, err := version.NewVersion(productVersion) + return 0, 0, nil, fmt.Errorf(".rsrc section not found") +} - if err != nil || semanticProductVersion == nil { - log.Tracef("Unable to create semantic version from portable executable product version %s", productVersion) - return "" +// parseResourceDirectory recursively parses a PE resource directory. This takes a relative virtual address (offset of +// a piece of data or code relative to the base address), the size of the resource directory, the set of RVAs already +// parsed, and the map to populate discovered version resource values. +// +// .rsrc Section +// +------------------------------+ +// | Resource Directory Table | +// +------------------------------+ +// | Resource Directory Entries | +// | +------------------------+ | +// | | Subdirectory or Data | | +// | +------------------------+ | +// +------------------------------+ +// | Resource Data Entries | +// | +------------------------+ | +// | | Resource Data | | +// | +------------------------+ | +// +------------------------------+ +// | Actual Resource Data | +// +------------------------------+ +// +// sources: +// - https://learn.microsoft.com/en-us/windows/win32/debug/pe-format#the-rsrc-section +// - https://learn.microsoft.com/en-us/previous-versions/ms809762(v=msdn.10)#pe-file-resources +func parseResourceDirectory(reader *bytes.Reader, rva, size, baseRVA uint32, dirs []uint32, fields map[string]string) error { + if size <= 0 { + return nil } - semanticFileVersion, err := version.NewVersion(fileVersion) + var directoryHeader peImageResourceDirectory - if err != nil || semanticFileVersion == nil { - log.Tracef("Unable to create semantic version from portable executable file version %s", fileVersion) - return productVersion + offset := int64(rva - baseRVA) + if _, err := reader.Seek(offset, io.SeekStart); err != nil { + return fmt.Errorf("error seeking to directory offset: %w", err) } - // Make no choice when they are semantically equal so that it falls - // through to the other comparison cases - if semanticProductVersion.Equal(semanticFileVersion) { - return "" + if err := readIntoStruct(reader, &directoryHeader); err != nil { + return fmt.Errorf("error reading directory header: %w", err) } - if semanticFileVersion.GreaterThan(semanticProductVersion) { - return fileVersion + numEntries := int(directoryHeader.NumberOfNamedEntries + directoryHeader.NumberOfIDEntries) + switch { + case numEntries > peMaxAllowedDirectoryEntries: + return fmt.Errorf("too many entries in resource directory: %d", numEntries) + case numEntries == 0: + return fmt.Errorf("no entries in resource directory") + case numEntries < 0: + return fmt.Errorf("invalid number of entries in resource directory: %d", numEntries) } - return productVersion -} + for i := 0; i < numEntries; i++ { + var entry peImageResourceDirectoryEntry -func findVersion(versionResources map[string]string) string { - productVersion := extractVersion(versionResources["ProductVersion"]) - fileVersion := extractVersion(versionResources["FileVersion"]) + entryOffset := offset + int64(binary.Size(directoryHeader)) + int64(i*binary.Size(entry)) + if _, err := reader.Seek(entryOffset, io.SeekStart); err != nil { + log.Tracef("error seeking to PE entry offset: %v", err) + continue + } - semanticVersionCompareResult := keepGreaterSemanticVersion(productVersion, fileVersion) + if err := readIntoStruct(reader, &entry); err != nil { + continue + } - if semanticVersionCompareResult != "" { - return semanticVersionCompareResult + // if the high bit is set, this is a directory entry, otherwise it is a data entry + isDirectory := entry.OffsetToData&0x80000000 != 0 + + // note: the offset is relative to the beginning of the resource section, not an RVA + entryOffsetToData := entry.OffsetToData & 0x7FFFFFFF + + if isDirectory { + subRVA := baseRVA + entryOffsetToData + if intInSlice(subRVA, dirs) { + // some malware uses recursive PE references to evade analysis + log.Tracef("recursive PE reference detected; skipping directory at rva=0x%x", subRVA) + continue + } + + dirs = append(dirs, subRVA) + err := parseResourceDirectory(reader, subRVA, size-(rva-baseRVA), baseRVA, dirs, fields) + if err != nil { + return err + } + } else { + err := parseResourceDataEntry(reader, baseRVA, baseRVA+entryOffsetToData, size, fields) + if err != nil { + return err + } + } } - productVersionDetail := punctuationCount(productVersion) - fileVersionDetail := punctuationCount(fileVersion) + return nil +} - if containsNumber(productVersion) && productVersionDetail >= fileVersionDetail { - return productVersion +// intInSlice checks weather a uint32 exists in a slice of uint32. +func intInSlice(a uint32, list []uint32) bool { + for _, b := range list { + if b == a { + return true + } } + return false +} + +func parseResourceDataEntry(reader *bytes.Reader, baseRVA, rva, remainingSize uint32, fields map[string]string) error { + var dataEntry peImageResourceDataEntry + offset := int64(rva - baseRVA) - if containsNumber(fileVersion) && fileVersionDetail > 0 { - return fileVersion + if _, err := reader.Seek(offset, io.SeekStart); err != nil { + return fmt.Errorf("error seeking to data entry offset: %w", err) } - if containsNumber(productVersion) { - return productVersion + if err := readIntoStruct(reader, &dataEntry); err != nil { + return fmt.Errorf("error reading resource data entry: %w", err) } - if containsNumber(fileVersion) { - return fileVersion + if remainingSize < dataEntry.Size { + return fmt.Errorf("resource data entry size exceeds remaining size") } - return productVersion -} + data := make([]byte, dataEntry.Size) + if _, err := reader.Seek(int64(dataEntry.OffsetToData-baseRVA), io.SeekStart); err != nil { + return fmt.Errorf("error seeking to resource data: %w", err) + } -func containsNumber(s string) bool { - return numberRegex.MatchString(s) -} + if _, err := reader.Read(data); err != nil { + return fmt.Errorf("error reading resource data: %w", err) + } -func punctuationCount(s string) int { - return len(versionPunctuationRegex.FindAllString(s, -1)) + return parseVersionResourceSection(bytes.NewReader(data), fields) } -var ( - // spaceRegex includes nbsp (#160) considered to be a space character - spaceRegex = regexp.MustCompile(`[\s\xa0]+`) - numberRegex = regexp.MustCompile(`\d`) - versionPunctuationRegex = regexp.MustCompile(`[.,]+`) -) +// parseVersionResourceSection parses a PE version resource section from within a resource directory. +// +// "The main structure in a version resource is the VS_FIXEDFILEINFO structure. Additional structures include the +// VarFileInfo structure to store language information data, and StringFileInfo for user-defined string information. +// All strings in a version resource are in Unicode format. Each block of information is aligned on a DWORD boundary." +// +// "VS_VERSIONINFO" (utf16) +// +---------------------------------------------------+ +// | wLength (2 bytes) | +// | wValueLength (2 bytes) | +// | wType (2 bytes) | +// | szKey ("VS_VERSION_INFO") (utf16) | +// | Padding (to DWORD) | +// +---------------------------------------------------+ +// | VS_FIXEDFILEINFO (52 bytes) | +// +---------------------------------------------------+ +// | "StringFileInfo" (utf16) | +// +---------------------------------------------------+ +// | wLength (2 bytes) | +// | wValueLength (2 bytes) | +// | wType (2 bytes) | +// | szKey ("StringFileInfo") (utf16) | +// | Padding (to DWORD) | +// | StringTable | +// | +--------------------------------------------+ | +// | | wLength (2 bytes) | | +// | | wValueLength (2 bytes) | | +// | | wType (2 bytes) | | +// | | szKey ("040904b0") | | +// | | Padding (to DWORD) | | +// | | String | | +// | | +--------------------------------------+ | | +// | | | wLength (2 bytes) | | | +// | | | wValueLength (2 bytes) | | | +// | | | wType (2 bytes) | | | +// | | | szKey ("FileVersion") | | | +// | | | Padding (to DWORD) | | | +// | | | szValue ("15.00.0913.015") | | | +// | | | Padding (to DWORD) | | | +// | +--------------------------------------------+ | +// +---------------------------------------------------+ +// | VarFileInfo (utf16) | +// +---------------------------------------------------+ +// | (skip!) | +// +---------------------------------------------------+ +// +// sources: +// - https://learn.microsoft.com/en-us/windows/win32/menurc/resource-file-formats +// - https://learn.microsoft.com/en-us/windows/win32/menurc/vs-versioninfo +// - https://learn.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo +// - https://learn.microsoft.com/en-us/windows/win32/menurc/varfileinfo +// - https://learn.microsoft.com/en-us/windows/win32/menurc/stringfileinfo +// - https://learn.microsoft.com/en-us/windows/win32/menurc/stringtable +func parseVersionResourceSection(reader *bytes.Reader, fields map[string]string) error { + offset := 0 + + var info peVsVersionInfo + if szKey, err := readIntoStructAndSzKey(reader, &info, &offset); err != nil { + return fmt.Errorf("error reading PE version info: %v", err) + } else if szKey != "VS_VERSION_INFO" { + // this is a resource section, but not the version resources + return nil + } -func findName(versionResources map[string]string) string { - // PE files found in the wild _not_ authored by Microsoft seem to use ProductName as a clear - // identifier of the software - nameFields := []string{"ProductName", "FileDescription", "InternalName", "OriginalFilename"} + if err := alignAndSeek(reader, &offset); err != nil { + return fmt.Errorf("error aligning past PE version info: %w", err) + } - if isMicrosoft(versionResources) { - // Microsoft seems to be consistent using the FileDescription, with a few that are blank and have - // fallbacks to ProductName last, as this is often something very broad like "Microsoft Windows" - nameFields = []string{"FileDescription", "InternalName", "OriginalFilename", "ProductName"} + var fixedFileInfo peVsFixedFileInfo + if err := readIntoStruct(reader, &fixedFileInfo, &offset); err != nil { + return fmt.Errorf("error reading PE FixedFileInfo: %v", err) } - for _, field := range nameFields { - value := spaceNormalize(versionResources[field]) - if value == "" { + for reader.Len() > 0 { + if err := alignAndSeek(reader, &offset); err != nil { + return fmt.Errorf("error seeking to PE StringFileInfo: %w", err) + } + + var sfiHeader peStringFileInfo + if szKey, err := readIntoStructAndSzKey(reader, &sfiHeader, &offset); err != nil { + return fmt.Errorf("error reading PE string file info header: %v", err) + } else if szKey != "StringFileInfo" { + // we only care about extracting strings from any string tables, skip this + offset += int(sfiHeader.ValueLength) continue } - return value + + var stOffset int + + // note: the szKey for the prStringTable is the language + var stHeader peStringTable + if _, err := readIntoStructAndSzKey(reader, &stHeader, &offset, &stOffset); err != nil { + return fmt.Errorf("error reading PE string table header: %v", err) + } + + for stOffset < int(stHeader.Length) { + var stringHeader peString + if err := readIntoStruct(reader, &stringHeader, &offset, &stOffset); err != nil { + break + } + + key := readUTF16(reader, &offset, &stOffset) + + if err := alignAndSeek(reader, &offset, &stOffset); err != nil { + return fmt.Errorf("error aligning to next PE string table value: %w", err) + } + + var value string + if stringHeader.ValueLength > 0 { + value = readUTF16(reader, &offset, &stOffset) + } + + fields[key] = value + // TODO: change to trace log? + // log.WithFields("key", key, "value", value).Warn("found PE string table entry") + + if err := alignAndSeek(reader, &offset, &stOffset); err != nil { + return fmt.Errorf("error aligning to next PE string table key: %w", err) + } + } + } + + if fields["FileVersion"] == "" { + // we can derive the file version from the fixed file info if it is not already specified as a string entry + fields["FileVersion"] = fmt.Sprintf("%d.%d.%d.%d", + fixedFileInfo.FileVersionMS>>16, fixedFileInfo.FileVersionMS&0xFFFF, + fixedFileInfo.FileVersionLS>>16, fixedFileInfo.FileVersionLS&0xFFFF) + } + + return nil +} + +func readIntoStructAndSzKey[T any](reader *bytes.Reader, data *T, offsets ...*int) (string, error) { + if err := readIntoStruct(reader, data, offsets...); err != nil { + return "", err + } + return readUTF16(reader, offsets...), nil +} + +func readIntoStruct[T any](reader io.Reader, data *T, offsets ...*int) error { + if err := binary.Read(reader, binary.LittleEndian, data); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + + for i := range offsets { + *offsets[i] += binary.Size(*data) + } + return nil +} + +func alignAndSeek(reader io.Seeker, offset *int, trackOffsets ...*int) error { + ogOffset := *offset + *offset = alignToDWORD(*offset) + diff := *offset - ogOffset + for i := range trackOffsets { + *trackOffsets[i] += diff } + _, err := reader.Seek(int64(*offset), io.SeekStart) + return err +} - return "" +func alignToDWORD(offset int) int { + return (offset + 3) & ^3 } -// normalizes a string to a trimmed version with all contigous whitespace collapsed to a single space character -func spaceNormalize(value string) string { - value = strings.TrimSpace(value) - if value == "" { +func readUTF16(reader *bytes.Reader, offsets ...*int) string { + var result []rune + for { + var char uint16 + if err := binary.Read(reader, binary.LittleEndian, &char); err != nil || char == 0 { + break + } + result = append(result, rune(char)) + } + if len(result) == 0 { return "" } - // ensure valid utf8 text - value = strings.ToValidUTF8(value, "") - // consolidate all space characters - value = spaceRegex.ReplaceAllString(value, " ") - // remove other non-space, non-printable characters - value = regexp.MustCompile(`[\x00-\x1f]`).ReplaceAllString(value, "") - // consolidate all space characters again in case other non-printables were in-between - value = spaceRegex.ReplaceAllString(value, " ") - // finally, remove any remaining surrounding whitespace - value = strings.TrimSpace(value) - return value -} - -func isMicrosoft(versionResources map[string]string) bool { - return strings.Contains(strings.ToLower(versionResources["CompanyName"]), "microsoft") || - strings.Contains(strings.ToLower(versionResources["ProductName"]), "microsoft") + + for i := range offsets { + *offsets[i] += len(result)*2 + 2 // utf-16 characters + null terminator + } + return string(result) } diff --git a/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable_test.go b/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable_test.go index 2be417d6a8e..6955326fb33 100644 --- a/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable_test.go +++ b/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable_test.go @@ -273,7 +273,7 @@ func TestParseDotnetPortableExecutable(t *testing.T) { f := file.LocationReadCloser{ Location: location, } - got, err := buildDotNetPackage(tc.versionResources, f) + got, err := newDotnetBinaryPackage(tc.versionResources, f) assert.NoErrorf(t, err, "failed to build package from version resources: %+v", tc.versionResources) // ignore certain metadata @@ -288,7 +288,7 @@ func TestParseDotnetPortableExecutable(t *testing.T) { tc.expectedPackage.Language = pkg.Dotnet } if tc.expectedPackage.PURL == "" { - tc.expectedPackage.PURL = portableExecutablePackageURL(tc.expectedPackage.Name, tc.expectedPackage.Version) + tc.expectedPackage.PURL = binaryPackageURL(tc.expectedPackage.Name, tc.expectedPackage.Version) } tc.expectedPackage.Locations = file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)) @@ -325,7 +325,7 @@ func Test_extractVersion(t *testing.T) { for _, test := range tests { t.Run(test.input, func(t *testing.T) { - got := extractVersion(test.input) + got := extractVersionFromResourcesValue(test.input) assert.Equal(t, test.expected, got) }) } From e3109c3999ac025e0330d41b22393487978a8b6c Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 16 Jan 2025 12:52:43 -0500 Subject: [PATCH 2/2] [wip] add tests Signed-off-by: Alex Goodman --- syft/pkg/cataloger/dotnet/cataloger.go | 3 +- syft/pkg/cataloger/dotnet/cataloger_test.go | 97 +++++++++++++++++++ .../parse_dotnet_portable_executable.go | 45 ++++++++- .../image-net8-app-self-contained/Dockerfile | 16 +++ .../src/Program.cs | 31 ++++++ .../src/dotnetapp.csproj | 14 +++ .../image-net8-app-single-file/Dockerfile | 16 +++ .../image-net8-app-single-file/src/Program.cs | 31 ++++++ .../src/dotnetapp.csproj | 14 +++ .../test-fixtures/image-net8-app/Dockerfile | 16 +++ .../image-net8-app/src/Program.cs | 31 ++++++ .../image-net8-app/src/dotnetapp.csproj | 14 +++ 12 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/Dockerfile create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/Program.cs create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/dotnetapp.csproj create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/Dockerfile create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/Program.cs create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/dotnetapp.csproj create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/Dockerfile create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/Program.cs create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/dotnetapp.csproj diff --git a/syft/pkg/cataloger/dotnet/cataloger.go b/syft/pkg/cataloger/dotnet/cataloger.go index 74ad55cfb99..0dc79f74467 100644 --- a/syft/pkg/cataloger/dotnet/cataloger.go +++ b/syft/pkg/cataloger/dotnet/cataloger.go @@ -17,5 +17,6 @@ func NewDotnetDepsCataloger() pkg.Cataloger { // NewDotnetPortableExecutableCataloger returns a new Dotnet cataloger object base on portable executable files. func NewDotnetPortableExecutableCataloger() pkg.Cataloger { return generic.NewCataloger("dotnet-portable-executable-cataloger"). - WithParserByGlobs(parseDotnetPortableExecutable, "**/*.dll", "**/*.exe") + WithParserByGlobs(parseDotnetPortableExecutable, "**/*.dll", "**/*.exe"). + WithProcessors(mergeDotnetPEs) } diff --git a/syft/pkg/cataloger/dotnet/cataloger_test.go b/syft/pkg/cataloger/dotnet/cataloger_test.go index 7817d23152a..635c57e7b43 100644 --- a/syft/pkg/cataloger/dotnet/cataloger_test.go +++ b/syft/pkg/cataloger/dotnet/cataloger_test.go @@ -1,6 +1,7 @@ package dotnet import ( + "github.com/anchore/syft/syft/file" "testing" "github.com/anchore/syft/syft/pkg" @@ -42,3 +43,99 @@ func TestCataloger_Globs(t *testing.T) { }) } } + +func Test_ELF_Package_Cataloger(t *testing.T) { + + cases := []struct { + name string + fixture string + expected []pkg.Package + }{ + { + name: "go case", + fixture: "image-net8-app", + expected: []pkg.Package{ + { + Name: "Humanizer (net6.0)", + Version: "2.14.1.48190", + Locations: file.NewLocationSet( + file.NewVirtualLocation("/app/lib.dll", "/app/lib.dll"), + ), + Licenses: pkg.NewLicenseSet( + pkg.License{Value: "MIT", SPDXExpression: "MIT", Type: "declared"}, + ), + + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPortableExecutableEntry{}, + }, + { + Name: "Humanizer (net6.0)", + Version: "2.14.1.48190", + Locations: file.NewLocationSet( + file.NewVirtualLocation("/app/lib.dll", "/app/lib.dll"), + ), + Licenses: pkg.NewLicenseSet( + pkg.License{Value: "MIT", SPDXExpression: "MIT", Type: "declared"}, + ), + + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPortableExecutableEntry{}, + }, + { + Name: "Humanizer (netstandard2.0)", + Version: "2.14.1.48190", + Locations: file.NewLocationSet( + file.NewVirtualLocation("/app/lib.dll", "/app/lib.dll"), + ), + Licenses: pkg.NewLicenseSet( + pkg.License{Value: "MIT", SPDXExpression: "MIT", Type: "declared"}, + ), + + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPortableExecutableEntry{}, + }, + { + Name: "Json.NET .NET 6.0", + Version: "13.0.3.27908", + Locations: file.NewLocationSet( + file.NewVirtualLocation("/app/lib.dll", "/app/lib.dll"), + ), + Licenses: pkg.NewLicenseSet( + pkg.License{Value: "MIT", SPDXExpression: "MIT", Type: "declared"}, + ), + + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPortableExecutableEntry{}, + }, + { + Name: "dotnetapp", + Version: "1.0.0.0", + Locations: file.NewLocationSet( + file.NewVirtualLocation("/app/lib.dll", "/app/lib.dll"), + ), + Licenses: pkg.NewLicenseSet( + pkg.License{Value: "MIT", SPDXExpression: "MIT", Type: "declared"}, + ), + + Type: pkg.DotnetPkg, + Metadata: pkg.DotnetPortableExecutableEntry{}, + }, + }, + }, + } + + for _, v := range cases { + t.Run(v.name, func(t *testing.T) { + //for i := range v.expected { + // p := &v.expected[i] + // p.SetID() + //} + pkgtest.NewCatalogTester(). + WithImageResolver(t, v.fixture). + IgnoreLocationLayer(). // this fixture can be rebuilt, thus the layer ID will change + Expects(v.expected, nil). + TestCataloger(t, NewDotnetPortableExecutableCataloger()) + }) + } + +} diff --git a/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go b/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go index d909436eeab..7cad0a186a5 100644 --- a/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go +++ b/syft/pkg/cataloger/dotnet/parse_dotnet_portable_executable.go @@ -7,14 +7,14 @@ import ( "encoding/binary" "errors" "fmt" - "io" - "github.com/anchore/syft/internal/log" "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/cpe" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/unionreader" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" + "io" ) const peMaxAllowedDirectoryEntries = 0x1000 @@ -564,3 +564,44 @@ func readUTF16(reader *bytes.Reader, offsets ...*int) string { } return string(result) } + +func mergeDotnetPEs(pkgs []pkg.Package, rels []artifact.Relationship, givenErr error) ([]pkg.Package, []artifact.Relationship, error) { + // merge packages by package.ID, if they are the same ID then merge the package + pkgsByID := make(map[artifact.ID]*pkg.Package) + var extra []pkg.Package + for i := range pkgs { + p := &pkgs[i] + mId, err := artifact.IDByHash(p.Metadata) + if err != nil { + log.WithFields("error", err).Trace("unable to hash dotnet package metadata") + extra = append(extra, *p) + continue + } + if existingPkg, ok := pkgsByID[mId]; ok { + merge(existingPkg, p) + continue + } + pkgsByID[mId] = p + } + var finalPkgs []pkg.Package + for _, p := range pkgsByID { + finalPkgs = append(finalPkgs, *p) + } + finalPkgs = append(finalPkgs, extra...) + + pkg.Sort(finalPkgs) + + // TODO: once relationships are supported, then merge those as well + return finalPkgs, rels, givenErr +} + +func merge(p, other *pkg.Package) { + p.Locations.Add(other.Locations.ToSlice()...) + p.Licenses.Add(other.Licenses.ToSlice()...) + + p.CPEs = cpe.Merge(p.CPEs, other.CPEs) + + if p.PURL == "" { + p.PURL = other.PURL + } +} diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/Dockerfile b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/Dockerfile new file mode 100644 index 00000000000..7747a7449a7 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/Dockerfile @@ -0,0 +1,16 @@ +FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +ARG TARGETARCH=x64 +WORKDIR /src + +# copy csproj and restore as distinct layers +COPY src/*.csproj . +RUN dotnet restore --arch $TARGETARCH --verbosity normal + +# copy and publish app and libraries (self-contained!) +COPY src/ . +RUN dotnet publish --arch $TARGETARCH --self-contained --no-restore -o /app + + +FROM scratch +WORKDIR /app +COPY --from=build /app . diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/Program.cs b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/Program.cs new file mode 100644 index 00000000000..af4200bf147 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/Program.cs @@ -0,0 +1,31 @@ +using System; +using System.Net; +using System.Runtime.InteropServices; +using static System.Console; + +WriteLine("Runtime and Environment Information"); + +// OS and .NET information +WriteLine($"{nameof(RuntimeInformation.OSArchitecture)}: {RuntimeInformation.OSArchitecture}"); +WriteLine($"{nameof(RuntimeInformation.OSDescription)}: {RuntimeInformation.OSDescription}"); +WriteLine($"{nameof(RuntimeInformation.FrameworkDescription)}: {RuntimeInformation.FrameworkDescription}"); +WriteLine(); + +// Environment information +WriteLine($"{nameof(Environment.UserName)}: {Environment.UserName}"); +WriteLine($"HostName: {Dns.GetHostName()}"); +WriteLine($"{nameof(Environment.ProcessorCount)}: {Environment.ProcessorCount}"); +WriteLine(); + +// Memory information +WriteLine($"Available Memory (GC): {GetInBestUnit(GC.GetGCMemoryInfo().TotalAvailableMemoryBytes)}"); + +string GetInBestUnit(long size) +{ + const double Mebi = 1024 * 1024; + const double Gibi = Mebi * 1024; + + if (size < Mebi) return $"{size} bytes"; + if (size < Gibi) return $"{size / Mebi:F} MiB"; + return $"{size / Gibi:F} GiB"; +} diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/dotnetapp.csproj b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/dotnetapp.csproj new file mode 100644 index 00000000000..dc93536c122 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-self-contained/src/dotnetapp.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + + + + + + + + diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/Dockerfile b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/Dockerfile new file mode 100644 index 00000000000..c6743e66938 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/Dockerfile @@ -0,0 +1,16 @@ +FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +ARG TARGETARCH=x64 +WORKDIR /src + +# copy csproj and restore as distinct layers +COPY src/*.csproj . +RUN dotnet restore --arch $TARGETARCH --verbosity normal + +# copy and publish app and libraries (single file) +COPY src/ . +RUN dotnet publish --arch $TARGETARCH -p:IncludeNativeLibrariesForSelfExtract=true -p:PublishReadyToRun=true -p:PublishSingleFile=true -p:PublishTrimmed=true --no-restore -o /app + + +FROM scratch +WORKDIR /app +COPY --from=build /app . diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/Program.cs b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/Program.cs new file mode 100644 index 00000000000..af4200bf147 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/Program.cs @@ -0,0 +1,31 @@ +using System; +using System.Net; +using System.Runtime.InteropServices; +using static System.Console; + +WriteLine("Runtime and Environment Information"); + +// OS and .NET information +WriteLine($"{nameof(RuntimeInformation.OSArchitecture)}: {RuntimeInformation.OSArchitecture}"); +WriteLine($"{nameof(RuntimeInformation.OSDescription)}: {RuntimeInformation.OSDescription}"); +WriteLine($"{nameof(RuntimeInformation.FrameworkDescription)}: {RuntimeInformation.FrameworkDescription}"); +WriteLine(); + +// Environment information +WriteLine($"{nameof(Environment.UserName)}: {Environment.UserName}"); +WriteLine($"HostName: {Dns.GetHostName()}"); +WriteLine($"{nameof(Environment.ProcessorCount)}: {Environment.ProcessorCount}"); +WriteLine(); + +// Memory information +WriteLine($"Available Memory (GC): {GetInBestUnit(GC.GetGCMemoryInfo().TotalAvailableMemoryBytes)}"); + +string GetInBestUnit(long size) +{ + const double Mebi = 1024 * 1024; + const double Gibi = Mebi * 1024; + + if (size < Mebi) return $"{size} bytes"; + if (size < Gibi) return $"{size / Mebi:F} MiB"; + return $"{size / Gibi:F} GiB"; +} diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/dotnetapp.csproj b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/dotnetapp.csproj new file mode 100644 index 00000000000..dc93536c122 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app-single-file/src/dotnetapp.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + + + + + + + + diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/Dockerfile b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/Dockerfile new file mode 100644 index 00000000000..d8b9cbdd402 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/Dockerfile @@ -0,0 +1,16 @@ +FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +ARG TARGETARCH=x64 +WORKDIR /src + +# copy csproj and restore as distinct layers +COPY src/*.csproj . +RUN dotnet restore --arch $TARGETARCH --verbosity normal + +# copy and publish app and libraries +COPY src/ . +RUN dotnet publish --arch $TARGETARCH --no-restore -o /app + + +FROM scratch +WORKDIR /app +COPY --from=build /app . diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/Program.cs b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/Program.cs new file mode 100644 index 00000000000..af4200bf147 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/Program.cs @@ -0,0 +1,31 @@ +using System; +using System.Net; +using System.Runtime.InteropServices; +using static System.Console; + +WriteLine("Runtime and Environment Information"); + +// OS and .NET information +WriteLine($"{nameof(RuntimeInformation.OSArchitecture)}: {RuntimeInformation.OSArchitecture}"); +WriteLine($"{nameof(RuntimeInformation.OSDescription)}: {RuntimeInformation.OSDescription}"); +WriteLine($"{nameof(RuntimeInformation.FrameworkDescription)}: {RuntimeInformation.FrameworkDescription}"); +WriteLine(); + +// Environment information +WriteLine($"{nameof(Environment.UserName)}: {Environment.UserName}"); +WriteLine($"HostName: {Dns.GetHostName()}"); +WriteLine($"{nameof(Environment.ProcessorCount)}: {Environment.ProcessorCount}"); +WriteLine(); + +// Memory information +WriteLine($"Available Memory (GC): {GetInBestUnit(GC.GetGCMemoryInfo().TotalAvailableMemoryBytes)}"); + +string GetInBestUnit(long size) +{ + const double Mebi = 1024 * 1024; + const double Gibi = Mebi * 1024; + + if (size < Mebi) return $"{size} bytes"; + if (size < Gibi) return $"{size / Mebi:F} MiB"; + return $"{size / Gibi:F} GiB"; +} diff --git a/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/dotnetapp.csproj b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/dotnetapp.csproj new file mode 100644 index 00000000000..dc93536c122 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/test-fixtures/image-net8-app/src/dotnetapp.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + + + + + + + +