From 089b9809442efe6596280769dd085f5d9df6417b Mon Sep 17 00:00:00 2001 From: Pierre Precourt Date: Wed, 29 May 2024 04:40:12 -0700 Subject: [PATCH] Extractor for Windows patch level. Uses the `dism /get-packages /online` command under the hood. PiperOrigin-RevId: 638239158 --- converter/converter_test.go | 108 +++++++++++----- extractor/standalone/list/list.go | 5 +- .../dismpatch/dismparser/dism_parser.go | 90 +++++++++++++ .../dismpatch/dismparser/dism_parser_test.go | 122 ++++++++++++++++++ .../dismparser/testdata/dism_testdata.txt | 36 ++++++ .../dismparser/testdata/err_testdata.txt | 72 +++++++++++ .../standalone/windows/dismpatch/extractor.go | 52 ++++++++ .../windows/dismpatch/extractor_linux.go | 53 ++++++++ .../windows/dismpatch/extractor_test.go | 108 ++++++++++++++++ .../windows/dismpatch/extractor_windows.go | 96 ++++++++++++++ .../dismpatch/winproducts/winproducts.go | 100 ++++++++++++++ .../dismpatch/winproducts/winproducts_test.go | 93 +++++++++++++ go.mod | 1 + go.sum | 2 + 14 files changed, 908 insertions(+), 30 deletions(-) create mode 100644 extractor/standalone/windows/dismpatch/dismparser/dism_parser.go create mode 100644 extractor/standalone/windows/dismpatch/dismparser/dism_parser_test.go create mode 100644 extractor/standalone/windows/dismpatch/dismparser/testdata/dism_testdata.txt create mode 100644 extractor/standalone/windows/dismpatch/dismparser/testdata/err_testdata.txt create mode 100644 extractor/standalone/windows/dismpatch/extractor.go create mode 100644 extractor/standalone/windows/dismpatch/extractor_linux.go create mode 100644 extractor/standalone/windows/dismpatch/extractor_test.go create mode 100644 extractor/standalone/windows/dismpatch/extractor_windows.go create mode 100644 extractor/standalone/windows/dismpatch/winproducts/winproducts.go create mode 100644 extractor/standalone/windows/dismpatch/winproducts/winproducts_test.go diff --git a/converter/converter_test.go b/converter/converter_test.go index a503b703..39e99207 100644 --- a/converter/converter_test.go +++ b/converter/converter_test.go @@ -26,6 +26,7 @@ import ( "github.com/google/osv-scalibr/extractor" "github.com/google/osv-scalibr/extractor/filesystem/language/python/wheelegg" "github.com/google/osv-scalibr/extractor/filesystem/sbom/spdx" + "github.com/google/osv-scalibr/extractor/standalone/windows/dismpatch" "github.com/google/osv-scalibr/purl" scalibr "github.com/google/osv-scalibr" ) @@ -322,41 +323,90 @@ func TestToSPDX23(t *testing.T) { func TestToPURL(t *testing.T) { pipEx := wheelegg.New(wheelegg.DefaultConfig()) - inventory := &extractor.Inventory{ - Name: "software", - Version: "1.0.0", - Locations: []string{"/file1"}, - Extractor: pipEx, - } - want := &purl.PackageURL{ - Type: purl.TypePyPi, - Name: "software", - Version: "1.0.0", - } - got, err := converter.ToPURL(inventory) - if err != nil { - t.Fatalf("converter.ToPURL(%v): %v", inventory, err) + tests := []struct { + desc string + inventory *extractor.Inventory + want *purl.PackageURL + wantErr bool + }{ + { + desc: "Valid inventory extractor", + inventory: &extractor.Inventory{ + Name: "software", + Version: "1.0.0", + Locations: []string{"/file1"}, + Extractor: pipEx, + }, + want: &purl.PackageURL{ + Type: purl.TypePyPi, + Name: "software", + Version: "1.0.0", + }, + }, + { + desc: "Windows-only returns error", + inventory: &extractor.Inventory{ + Name: "irrelevant", + Extractor: dismpatch.Extractor{}, + Locations: []string{"irrelevant"}, + Version: "irrelevant", + }, + wantErr: true, + }, } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("converter.ToPURL(%v) returned unexpected diff (-want +got):\n%s", inventory, diff) + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := converter.ToPURL(tc.inventory) + if err != nil && !tc.wantErr || err == nil && tc.wantErr { + t.Fatalf("converter.ToPURL(%v): %v", tc.inventory, err) + } + + if tc.wantErr == true { + return + } + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("converter.ToPURL(%v) returned unexpected diff (-want +got):\n%s", tc.inventory, diff) + } + }) } } func TestToCPEs(t *testing.T) { - cpes := []string{"cpe:2.3:a:nginx:nginx:1.21.1"} - inventory := &extractor.Inventory{ - Name: "nginx", - Metadata: &spdx.Metadata{ - CPEs: cpes, + tests := []struct { + desc string + inventory *extractor.Inventory + want []string + wantErr bool + }{ + { + desc: "Valid fileststem extractor", + inventory: &extractor.Inventory{ + Name: "nginx", + Metadata: &spdx.Metadata{ + CPEs: []string{"cpe:2.3:a:nginx:nginx:1.21.1"}, + }, + Extractor: &spdx.Extractor{}, + }, + want: []string{"cpe:2.3:a:nginx:nginx:1.21.1"}, }, - Extractor: &spdx.Extractor{}, } - want := cpes - got, err := converter.ToCPEs(inventory) - if err != nil { - t.Fatalf("converter.ToCPEs(%v): %v", inventory, err) - } - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("converter.ToCPEs(%v) returned unexpected diff (-want +got):\n%s", inventory, diff) + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, err := converter.ToCPEs(tc.inventory) + if err != nil && !tc.wantErr || err == nil && tc.wantErr { + t.Fatalf("converter.ToCPEs(%v): %v", tc.inventory, err) + } + + if tc.wantErr == true { + return + } + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("converter.ToCPEs(%v) returned unexpected diff (-want +got):\n%s", tc.inventory, diff) + } + }) } } diff --git a/extractor/standalone/list/list.go b/extractor/standalone/list/list.go index 302a664d..6a2746ca 100644 --- a/extractor/standalone/list/list.go +++ b/extractor/standalone/list/list.go @@ -22,12 +22,15 @@ import ( "strings" "github.com/google/osv-scalibr/extractor/standalone" + "github.com/google/osv-scalibr/extractor/standalone/windows/dismpatch" "github.com/google/osv-scalibr/log" ) var ( // Windows standalone extractors. - Windows = []standalone.Extractor{} + Windows = []standalone.Extractor{ + &dismpatch.Extractor{}, + } // Default standalone extractors. Default []standalone.Extractor = slices.Concat(Windows) diff --git a/extractor/standalone/windows/dismpatch/dismparser/dism_parser.go b/extractor/standalone/windows/dismpatch/dismparser/dism_parser.go new file mode 100644 index 00000000..df341025 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/dismparser/dism_parser.go @@ -0,0 +1,90 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package dismparser has methods that can be used to parse DISM output +package dismparser + +import ( + "errors" + "regexp" + "strings" +) + +var ( + // ErrParsingError indicates an error while parsing the DISM output. + ErrParsingError = errors.New("Could not parse DISM output successfully") + + versionRegexp = regexp.MustCompile(`~(\d+\.\d+\.\d+\.\d+)$`) +) + +// DismPkg reports information about a package as reported by the DISM tool. +type DismPkg struct { + PackageIdentity string + PackageVersion string + State string + ReleaseType string + InstallTime string +} + +// Parse parses dism output into an array of dismPkgs. +func Parse(input string) ([]DismPkg, string, error) { + pkgs := strings.Split(input, "Package Id") + + pkgExp, err := regexp.Compile("entity :(.*)\n*State :(.*)\n*Release Type :(.*)\n*Install Time :(.*)\n*") + if err != nil { + return nil, "", err + } + + imgExp, err := regexp.Compile("Image Version: (.*)") + if err != nil { + return nil, "", err + } + + imgVersion := "" + dismPkgs := []DismPkg{} + + for _, pkg := range pkgs { + matches := pkgExp.FindStringSubmatch(pkg) + if len(matches) > 4 { + dismPkg := DismPkg{ + PackageIdentity: strings.TrimSpace(matches[1]), + State: strings.TrimSpace(matches[2]), + ReleaseType: strings.TrimSpace(matches[3]), + InstallTime: strings.TrimSpace(matches[4]), + } + dismPkg.PackageVersion = findVersion(dismPkg.PackageIdentity) + dismPkgs = append(dismPkgs, dismPkg) + } else { + // this is the first entry that has the image version + matches = imgExp.FindStringSubmatch(pkg) + if len(matches) > 1 { + imgVersion = matches[1] + } + } + } + + if len(dismPkgs) == 0 { + return nil, "", ErrParsingError + } + + return dismPkgs, imgVersion, nil +} + +func findVersion(identity string) string { + pkgVer := versionRegexp.FindStringSubmatch(identity) + if len(pkgVer) > 1 { + return pkgVer[1] + } + return "" +} diff --git a/extractor/standalone/windows/dismpatch/dismparser/dism_parser_test.go b/extractor/standalone/windows/dismpatch/dismparser/dism_parser_test.go new file mode 100644 index 00000000..db231312 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/dismparser/dism_parser_test.go @@ -0,0 +1,122 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dismparser + +import ( + "os" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParse(t *testing.T) { + content, err := os.ReadFile("testdata/dism_testdata.txt") + if err != nil { + t.Fatalf("Failed to read testdata: %v", err) + } + + pkgs, imgVersion, err := Parse(string(content)) + if err != nil { + t.Errorf("Error while parsing the output: %v", err) + } + + if imgVersion != "10.0.17763.3406" { + t.Errorf("Parse, ImageVersion: Got: %v, Want: %v", imgVersion, "10.0.17763.3406") + } + + want := []DismPkg{ + DismPkg{ + PackageIdentity: "Microsoft-Windows-FodMetadata-Package~31bf3856ad364e35~amd64~~10.0.17763.1", + PackageVersion: "10.0.17763.1", + State: "Installed", + ReleaseType: "Feature Pack", + InstallTime: "9/15/2018 9:08 AM", + }, + DismPkg{ + PackageIdentity: "Package_for_KB4470788~31bf3856ad364e35~amd64~~17763.164.1.1", + PackageVersion: "17763.164.1.1", + State: "Installed", + ReleaseType: "Security Update", + InstallTime: "3/12/2019 6:27 AM", + }, + DismPkg{ + PackageIdentity: "Package_for_RollupFix~31bf3856ad364e35~amd64~~17763.3406.1.5", + PackageVersion: "17763.3406.1.5", + State: "Installed", + ReleaseType: "Security Update", + InstallTime: "9/13/2022 11:06 PM", + }, + DismPkg{ + PackageIdentity: "Package_for_RollupFix~31bf3856ad364e35~amd64~~17763.379.1.11", + PackageVersion: "17763.379.1.11", + State: "Superseded", + ReleaseType: "Security Update", + InstallTime: "3/12/2019 6:31 AM", + }, + DismPkg{ + PackageIdentity: "Package_for_ServicingStack_3232~31bf3856ad364e35~amd64~~17763.3232.1.1", + PackageVersion: "17763.3232.1.1", + State: "Installed", + ReleaseType: "Update", + InstallTime: "9/13/2022 10:46 PM", + }, + DismPkg{ + PackageIdentity: "Microsoft-Windows-WordPad-FoD-Package~31bf3856ad364e35~wow64~en-US~10.0.19041.1", + PackageVersion: "10.0.19041.1", + State: "Installed", + ReleaseType: "OnDemand Pack", + InstallTime: "12/7/2019 9:51 AM", + }, + } + + if diff := cmp.Diff(want, pkgs); diff != "" { + t.Errorf("Parse: Diff = %v", diff) + } +} + +func TestFindVersion(t *testing.T) { + type test struct { + input string + want string + } + + tests := []test{ + { + input: "Microsoft-Windows-FodMetadata-Package~31bf3856ad364e35~amd64~~10.0.17763.1", + want: "10.0.17763.1", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := findVersion(tt.input) + if got != tt.want { + t.Errorf("findVersion: Got: %v, Want: %v", got, tt.want) + } + }) + } +} + +func TestParseError(t *testing.T) { + content, err := os.ReadFile("testdata/err_testdata.txt") + if err != nil { + t.Fatalf("Failed to read testdata: %v", err) + } + + _, _, err = Parse(string(content)) + if err == nil || err.Error() != "Could not parse DISM output successfully" { + t.Errorf("Parse: Want: %v, Got: %v", "Could not parse DISM output successfully", err) + } +} diff --git a/extractor/standalone/windows/dismpatch/dismparser/testdata/dism_testdata.txt b/extractor/standalone/windows/dismpatch/dismparser/testdata/dism_testdata.txt new file mode 100644 index 00000000..519ac969 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/dismparser/testdata/dism_testdata.txt @@ -0,0 +1,36 @@ +Deployment Image Servicing and Management tool +Version: 10.0.20348.681 + +Image Version: 10.0.17763.3406 + +Package Identity : Microsoft-Windows-FodMetadata-Package~31bf3856ad364e35~amd64~~10.0.17763.1 +State : Installed +Release Type : Feature Pack +Install Time : 9/15/2018 9:08 AM + +Package Identity : Package_for_KB4470788~31bf3856ad364e35~amd64~~17763.164.1.1 +State : Installed +Release Type : Security Update +Install Time : 3/12/2019 6:27 AM + +Package Identity : Package_for_RollupFix~31bf3856ad364e35~amd64~~17763.3406.1.5 +State : Installed +Release Type : Security Update +Install Time : 9/13/2022 11:06 PM + +Package Identity : Package_for_RollupFix~31bf3856ad364e35~amd64~~17763.379.1.11 +State : Superseded +Release Type : Security Update +Install Time : 3/12/2019 6:31 AM + +Package Identity : Package_for_ServicingStack_3232~31bf3856ad364e35~amd64~~17763.3232.1.1 +State : Installed +Release Type : Update +Install Time : 9/13/2022 10:46 PM + +Package Identity : Microsoft-Windows-WordPad-FoD-Package~31bf3856ad364e35~wow64~en-US~10.0.19041.1 +State : Installed +Release Type : OnDemand Pack +Install Time : 12/7/2019 9:51 AM + +The operation completed successfully. diff --git a/extractor/standalone/windows/dismpatch/dismparser/testdata/err_testdata.txt b/extractor/standalone/windows/dismpatch/dismparser/testdata/err_testdata.txt new file mode 100644 index 00000000..062f5272 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/dismparser/testdata/err_testdata.txt @@ -0,0 +1,72 @@ +Deployment Image Servicing and Management tool +Version: 10.0.20348.681 + +Image Version: 10.0.17763.3406 + + Identity : Microsoft-Windows-FodMetadata-~31bf3856ad364e35~amd64~~10.0.17763.1 +State : Installed +Type : Feature Pack +Install Time : 9/15/2018 9:08 AM + + Identity : _for_KB4470788~31bf3856ad364e35~amd64~~17763.164.1.1 +State : Installed +Type : Security Update +Install Time : 3/12/2019 6:27 AM + + Identity : _for_KB4486153~31bf3856ad364e35~amd64~~10.0.1.3106 +State : Installed +Type : Update +Install Time : 9/13/2022 10:13 PM + + Identity : _for_KB4524244~31bf3856ad364e35~amd64~~10.0.1.4 +State : Installed +Type : Security Update +Install Time : 9/13/2022 10:14 PM + + Identity : _for_KB4535680~31bf3856ad364e35~amd64~~10.0.1.0 +State : Installed +Type : Security Update +Install Time : 9/13/2022 10:43 PM + + Identity : _for_KB4562562~31bf3856ad364e35~amd64~~17763.1270.1.0 +State : Installed +Type : Security Update +Install Time : 9/13/2022 10:14 PM + + Identity : _for_KB4577586~31bf3856ad364e35~amd64~~10.0.1.6 +State : Installed +Type : Update +Install Time : 9/13/2022 10:43 PM + + Identity : _for_KB4589208~31bf3856ad364e35~amd64~~10.0.2.4 +State : Installed +Type : Update +Install Time : 9/14/2022 6:09 AM + + Identity : _for_KB5012170~31bf3856ad364e35~amd64~~17763.3280.1.1 +State : Installed +Type : Security Update +Install Time : 9/13/2022 11:08 PM + + Identity : _for_RollupFix~31bf3856ad364e35~amd64~~17763.1282.1.9 +State : Superseded +Type : Security Update + +Install Time : 9/13/2022 10:24 PM + + Identity : _for_RollupFix~31bf3856ad364e35~amd64~~17763.3406.1.5 +State : Installed +Type : Security Update +Install Time : 9/13/2022 11:06 PM + + Identity : _for_RollupFix~31bf3856ad364e35~amd64~~17763.379.1.11 +State : Superseded +Type : Security Update +Install Time : 3/12/2019 6:31 AM + + Identity : _for_ServicingStack_3232~31bf3856ad364e35~amd64~~17763.3232.1.1 +State : Installed +Type : Update +Install Time : 9/13/2022 10:46 PM + +The operation completed successfully. diff --git a/extractor/standalone/windows/dismpatch/extractor.go b/extractor/standalone/windows/dismpatch/extractor.go new file mode 100644 index 00000000..730e146b --- /dev/null +++ b/extractor/standalone/windows/dismpatch/extractor.go @@ -0,0 +1,52 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dismpatch + +import ( + "strings" + + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/standalone/windows/dismpatch/dismparser" + "github.com/google/osv-scalibr/extractor/standalone/windows/dismpatch/winproducts" +) + +// inventoryFromOutput parses the output of DISM and produces inventory entries from it. +func inventoryFromOutput(flavor, output string) ([]*extractor.Inventory, error) { + packages, imgVersion, err := dismparser.Parse(string(output)) + if err != nil { + return nil, err + } + + imgVersion = strings.TrimSpace(imgVersion) + windowsProduct := winproducts.WindowsProductFromVersion(flavor, imgVersion) + inventory := []*extractor.Inventory{ + &extractor.Inventory{ + Name: windowsProduct, + Version: imgVersion, + Locations: []string{"cmd-dism"}, + }, + } + + // extract KB informations + for _, pkg := range packages { + inventory = append(inventory, &extractor.Inventory{ + Name: pkg.PackageIdentity, + Version: pkg.PackageVersion, + Locations: []string{"cmd-dism"}, + }) + } + + return inventory, nil +} diff --git a/extractor/standalone/windows/dismpatch/extractor_linux.go b/extractor/standalone/windows/dismpatch/extractor_linux.go new file mode 100644 index 00000000..710d2b26 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/extractor_linux.go @@ -0,0 +1,53 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux + +package dismpatch + +import ( + "context" + "fmt" + + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/standalone" + "github.com/google/osv-scalibr/purl" +) + +// Name of the extractor +const Name = "windows/dismpatch" + +// Extractor implements the dismpatch extractor. +type Extractor struct{} + +// Name of the extractor. +func (e Extractor) Name() string { return Name } + +// Version of the extractor. +func (e Extractor) Version() int { return 0 } + +// Extract is a no-op for Linux. +func (e *Extractor) Extract(ctx context.Context, input *standalone.ScanInput) ([]*extractor.Inventory, error) { + return nil, fmt.Errorf("only supported on Windows") +} + +// ToPURL converts an inventory created by this extractor into a PURL. +func (e Extractor) ToPURL(i *extractor.Inventory) (*purl.PackageURL, error) { + return nil, fmt.Errorf("only supported on Windows") +} + +// ToCPEs converts an inventory created by this extractor into CPEs, if supported. +func (e Extractor) ToCPEs(i *extractor.Inventory) ([]string, error) { + return nil, fmt.Errorf("only supported on Windows") +} diff --git a/extractor/standalone/windows/dismpatch/extractor_test.go b/extractor/standalone/windows/dismpatch/extractor_test.go new file mode 100644 index 00000000..bdc5f9c4 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/extractor_test.go @@ -0,0 +1,108 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dismpatch + +import ( + _ "embed" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/standalone/windows/dismpatch/dismparser" +) + +func TestInventoryFromOutput(t *testing.T) { + dismTestData, err := os.ReadFile("dismparser/testdata/dism_testdata.txt") + if err != nil { + t.Fatalf("Failed to read testdata: %v", err) + } + + tests := []struct { + desc string + flavor string + output string + want []*extractor.Inventory + wantErr error + }{ + { + desc: "Valid test data returns inventory", + flavor: "server", + output: string(dismTestData), + want: []*extractor.Inventory{ + &extractor.Inventory{ + Name: "windows_server_2019", + Version: "10.0.17763.3406", + Locations: []string{"cmd-dism"}, + }, + &extractor.Inventory{ + Name: "Microsoft-Windows-FodMetadata-Package~31bf3856ad364e35~amd64~~10.0.17763.1", + Version: "10.0.17763.1", + Locations: []string{"cmd-dism"}, + }, + &extractor.Inventory{ + Name: "Package_for_KB4470788~31bf3856ad364e35~amd64~~17763.164.1.1", + Version: "17763.164.1.1", + Locations: []string{"cmd-dism"}, + }, + &extractor.Inventory{ + Name: "Package_for_RollupFix~31bf3856ad364e35~amd64~~17763.3406.1.5", + Version: "17763.3406.1.5", + Locations: []string{"cmd-dism"}, + }, + &extractor.Inventory{ + Name: "Package_for_RollupFix~31bf3856ad364e35~amd64~~17763.379.1.11", + Version: "17763.379.1.11", + Locations: []string{"cmd-dism"}, + }, + &extractor.Inventory{ + Name: "Package_for_ServicingStack_3232~31bf3856ad364e35~amd64~~17763.3232.1.1", + Version: "17763.3232.1.1", + Locations: []string{"cmd-dism"}, + }, + &extractor.Inventory{ + Name: "Microsoft-Windows-WordPad-FoD-Package~31bf3856ad364e35~wow64~en-US~10.0.19041.1", + Version: "10.0.19041.1", + Locations: []string{"cmd-dism"}, + }, + }, + wantErr: nil, + }, + { + desc: "Empty output returns parsing error", + flavor: "server", + output: "", + want: nil, + wantErr: dismparser.ErrParsingError, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got, gotErr := inventoryFromOutput(tc.flavor, tc.output) + if gotErr != tc.wantErr { + t.Fatalf("inventoryFromOutput(%q, %q) returned an unexpected error: %v", tc.flavor, tc.output, gotErr) + } + + if tc.wantErr != nil { + return + } + + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("inventoryFromOutput(%q, %q) returned an unexpected diff (-want +got): %v", tc.flavor, tc.output, diff) + } + }) + } +} diff --git a/extractor/standalone/windows/dismpatch/extractor_windows.go b/extractor/standalone/windows/dismpatch/extractor_windows.go new file mode 100644 index 00000000..54029bd3 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/extractor_windows.go @@ -0,0 +1,96 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +// Package dismpatch extract patch level from the DISM command line tool. +package dismpatch + +import ( + "context" + "os/exec" + + "golang.org/x/sys/windows/registry" + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/standalone" + "github.com/google/osv-scalibr/extractor/standalone/windows/dismpatch/winproducts" + "github.com/google/osv-scalibr/purl" +) + +const ( + // Name of the extractor + Name = "windows/dismpatch" + + // Registry information to find the flavor of Windows. + registryPath = `SOFTWARE\Microsoft\Windows NT\CurrentVersion` + registryKey = "InstallationType" +) + +// Extractor implements the dismpatch extractor. +type Extractor struct{} + +// Name of the extractor. +func (e Extractor) Name() string { return Name } + +// Version of the extractor. +func (e Extractor) Version() int { return 0 } + +// Extract retrieves the patch level from the DISM command line tool. +func (e *Extractor) Extract(ctx context.Context, input *standalone.ScanInput) ([]*extractor.Inventory, error) { + output, err := runDISM(ctx) + if err != nil { + return nil, err + } + + installType := readInstallationTypeRegistry() + flavor := winproducts.WhichWindowsFlavor(installType) + return inventoryFromOutput(flavor, output) +} + +// ToPURL converts an inventory created by this extractor into a PURL. +func (e Extractor) ToPURL(i *extractor.Inventory) (*purl.PackageURL, error) { + return &purl.PackageURL{ + Type: purl.TypeGeneric, + Name: i.Name, + Version: i.Version, + }, nil +} + +// ToCPEs is not applicable as this extractor does not infer CPEs from the Inventory. +func (e Extractor) ToCPEs(i *extractor.Inventory) ([]string, error) { return []string{}, nil } + +// runDISM executes the dism command line tool. +func runDISM(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "dism", "/online", "/get-packages", "/format:list") + output, err := cmd.CombinedOutput() + return string(output), err +} + +// readInstallationTypeRegistry returns the lowercase value of the "InstallationType" registry key +// on the currently running system. +// Full key path: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\InstallationType +// Defaults to "server" if the key is not found. +func readInstallationTypeRegistry() string { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, registryPath, registry.QUERY_VALUE) + if err != nil { + return "server" + } + defer key.Close() + + if value, _, err := key.GetStringValue(registryKey); err == nil { + return value + } + + return "server" +} diff --git a/extractor/standalone/windows/dismpatch/winproducts/winproducts.go b/extractor/standalone/windows/dismpatch/winproducts/winproducts.go new file mode 100644 index 00000000..d125ca40 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/winproducts/winproducts.go @@ -0,0 +1,100 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package winproducts contains information about Windows products. +package winproducts + +import ( + "strings" + + "github.com/google/osv-scalibr/log" +) + +var ( + // windowsFlavorAndBuildToProductName maps a given Windows flavor and build number to a product + // name. The list of build numbers can be obtained on Wikipedia: + // https://en.wikipedia.org/wiki/List_of_Microsoft_Windows_versions + // The product name is normalized to be compatible with CPEs found on nvd.nist.gov. + windowsFlavorAndBuildToProductName = map[string]map[string]string{ + "server": { + "6.0.6003": "windows_server_2008", + "6.1.7601": "windows_server_2008:r2", + "6.2.9200": "windows_server_2012", + "6.3.9600": "windows_server_2012:r2", + "10.0.14393": "windows_server_2016", + "10.0.16299": "windows_server_1709", + "10.0.17134": "windows_server_1803", + "10.0.17763": "windows_server_2019", + "10.0.18362": "windows_server_1903", + "10.0.18363": "windows_server_1909", + "10.0.19041": "windows_server_2004", + "10.0.19042": "windows_server_20h2", + "10.0.20348": "windows_server_2022", + "10.0.25398": "windows_server_2022_23H2", + }, + "client": { + "5.1.2600": "windows_xp", + "10.0.10240": "windows_10_1507", + "10.0.14393": "windows_10_1607", + "10.0.17763": "windows_10_1809", + "10.0.19042": "windows_10_20H2", + "10.0.19043": "windows_10_21H1", + "10.0.19044": "windows_10_21H2", + "10.0.19045": "windows_10_22H2", + "10.0.22000": "windows_11_21H2", + "10.0.22621": "windows_11_22H2", + "10.0.22631": "windows_11_23H2", + }, + } +) + +// WhichWindowsFlavor returns the lowercase Windows flavor (server or client) of the current system +// using the provided lowercase installType (found in the registry). +// Defaults to "server" if we don't recognize the flavor, but log so that we can add it later. +func WhichWindowsFlavor(installType string) string { + flavor := strings.ToLower(installType) + + switch flavor { + case "client": + return "client" + + case "server": + case "server core": + return "server" + } + + log.Infof("Please report to scalibr devteam: unknown Windows flavor: %q", flavor) + return "server" +} + +// WindowsProductFromVersion fetches the current Windows product name from known products using +// the flavor (e.g. client / server) and the image version. +func WindowsProductFromVersion(flavor, imgVersion string) string { + knownVersions, ok := windowsFlavorAndBuildToProductName[flavor] + if !ok { + return "unknownWindows" + } + + imgVersionSplit := strings.Split(imgVersion, ".") + if len(imgVersionSplit) < 3 { + return "unknownWindows" + } + + version := strings.Join(imgVersionSplit[:3], ".") + if productName, ok := knownVersions[version]; ok { + return productName + } + + return "unknownWindows" +} diff --git a/extractor/standalone/windows/dismpatch/winproducts/winproducts_test.go b/extractor/standalone/windows/dismpatch/winproducts/winproducts_test.go new file mode 100644 index 00000000..edf155f4 --- /dev/null +++ b/extractor/standalone/windows/dismpatch/winproducts/winproducts_test.go @@ -0,0 +1,93 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package winproducts + +import ( + "testing" +) + +func TestWhichWindowsFlavor(t *testing.T) { + tests := []struct { + desc string + installType string + want string + }{ + { + desc: "Windows server returns server", + installType: "server", + want: "server", + }, + { + desc: "Windows server core returns server", + installType: "server core", + want: "server", + }, + { + desc: "Windows client returns client", + installType: "client", + want: "client", + }, + { + desc: "Ignore case", + installType: "SeRvEr", + want: "server", + }, + { + desc: "Unknown returns server", + installType: "unknown", + want: "server", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := WhichWindowsFlavor(tc.installType) + if got != tc.want { + t.Errorf("WhichWindowsFlavor(%q) = %q, want: %q", tc.installType, got, tc.want) + } + }) + } +} + +func TestWindowsProductFromVersion(t *testing.T) { + tests := []struct { + desc string + flavor string + imgVersion string + want string + }{ + { + desc: "Known version returns correct product", + flavor: "server", + imgVersion: "10.0.14393.1234", + want: "windows_server_2016", + }, + { + desc: "Unknown version returns unknownWindows", + flavor: "server", + imgVersion: "127.0.0.1", + want: "unknownWindows", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + got := WindowsProductFromVersion(tc.flavor, tc.imgVersion) + if got != tc.want { + t.Errorf("WindowsProductFromVersion(%q, %q) = %q, want: %q", tc.flavor, tc.imgVersion, got, tc.want) + } + }) + } +} diff --git a/go.mod b/go.mod index de130434..b7e9bb16 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/spdx/gordf v0.0.0-20221230105357-b735bd5aac89 // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.20.0 // indirect golang.org/x/tools v0.19.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 18ca5c44..8b20540f 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= golang.org/x/vuln v1.0.4 h1:SP0mPeg2PmGCu03V+61EcQiOjmpri2XijexKdzv8Z1I=