diff --git a/docs/supported_inventory_types.md b/docs/supported_inventory_types.md index 9f30e406..fe13d7e3 100644 --- a/docs/supported_inventory_types.md +++ b/docs/supported_inventory_types.md @@ -51,7 +51,9 @@ SCALIBR supports extracting software package information from a variety of OS an * Lockfiles: pom.xml, gradle.lockfile, verification-metadata.xml * Javascript * Installed NPM packages (package.json) - * Lockfiles: package-lock.json, yarn.lock, pnpm-lock.yaml + * Lockfiles: package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock +* ObjectiveC + * Podfile.lock * PHP: * Composer * Python @@ -65,9 +67,12 @@ SCALIBR supports extracting software package information from a variety of OS an * Lockfiles: Gemfile.lock (OSV) * Rust * Cargo.lock + * Rust binaries * Swift * Podfile.lock * Package.resolved +* Wordpress plugins + * Installed plugins ## Container inventory @@ -78,4 +83,4 @@ SCALIBR supports extracting software package information from a variety of OS an * SPDX SBOM descriptors * CycloneDX SBOM descriptors -If you're a SCALIBR user and are interested in having it support new inventory types we're happy to accept contributions. See the docs on [how to add a new Extractor](/docs/new_extractor.md). +If you're a SCALIBR user and are interested in having it support new inventory types we're happy to accept contributions. See the docs on [how to add a new Extractor](/docs/new_extractor.md). \ No newline at end of file diff --git a/extractor/filesystem/language/wordpress/plugins/plugins.go b/extractor/filesystem/language/wordpress/plugins/plugins.go new file mode 100644 index 00000000..2a73e9d2 --- /dev/null +++ b/extractor/filesystem/language/wordpress/plugins/plugins.go @@ -0,0 +1,196 @@ +// 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 wordpress extracts packages from installed plugins. +package plugins + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/filesystem" + "github.com/google/osv-scalibr/extractor/filesystem/internal/units" + "github.com/google/osv-scalibr/plugin" + "github.com/google/osv-scalibr/purl" + "github.com/google/osv-scalibr/stats" +) + +const ( + // Name is the unique name of this extractor. + Name = "wordpress/plugins" +) + +// Config is the configuration for the Wordpress extractor. +type Config struct { + // Stats is a stats collector for reporting metrics. + Stats stats.Collector + // MaxFileSizeBytes is the maximum file size this extractor will unmarshal. If + // `FileRequired` gets a bigger file, it will return false, + MaxFileSizeBytes int64 +} + +// DefaultConfig returns the default configuration for the Wordpress extractor. +func DefaultConfig() Config { + return Config{ + Stats: nil, + MaxFileSizeBytes: 10 * units.MiB, + } +} + +// Extractor structure for plugins files. +type Extractor struct { + stats stats.Collector + maxFileSizeBytes int64 +} + +// New returns a Wordpress extractor. +// +// For most use cases, initialize with: +// ``` +// e := New(DefaultConfig()) +// ``` +func New(cfg Config) *Extractor { + return &Extractor{ + stats: cfg.Stats, + maxFileSizeBytes: cfg.MaxFileSizeBytes, + } +} + +// Config returns the configuration of the extractor. +func (e Extractor) Config() Config { + return Config{ + Stats: e.stats, + MaxFileSizeBytes: e.maxFileSizeBytes, + } +} + +// Name of the extractor. +func (e Extractor) Name() string { return Name } + +// Version of the extractor. +func (e Extractor) Version() int { return 0 } + +// Requirements of the extractor. +func (e Extractor) Requirements() *plugin.Capabilities { return &plugin.Capabilities{} } + +// FileRequired returns true if the specified file matches the /wp-content/plugins/ pattern. +func (e Extractor) FileRequired(api filesystem.FileAPI) bool { + path := api.Path() + if !strings.HasSuffix(path, ".php") || !strings.Contains(path, "wp-content/plugins/") { + return false + } + + fileinfo, err := api.Stat() + if err != nil { + return false + } + + if e.maxFileSizeBytes > 0 && fileinfo.Size() > e.maxFileSizeBytes { + e.reportFileRequired(path, fileinfo.Size(), stats.FileRequiredResultSizeLimitExceeded) + return false + } + + e.reportFileRequired(path, fileinfo.Size(), stats.FileRequiredResultOK) + return true +} + +func (e Extractor) reportFileRequired(path string, fileSizeBytes int64, result stats.FileRequiredResult) { + if e.stats == nil { + return + } + e.stats.AfterFileRequired(e.Name(), &stats.FileRequiredStats{ + Path: path, + Result: result, + FileSizeBytes: fileSizeBytes, + }) +} + +// Extract parses the PHP file to extract Wordpress package. +func (e Extractor) Extract(ctx context.Context, input *filesystem.ScanInput) ([]*extractor.Inventory, error) { + if input == nil || input.Reader == nil { + return nil, fmt.Errorf("invalid input: nil reader") + } + + pkg, err := parsePHPFile(input.Reader) + if err != nil { + return nil, err + } + + if pkg == nil { + return nil, nil + } + + inventory := &extractor.Inventory{ + Name: pkg.Name, + Version: pkg.Version, + Locations: []string{input.Path}, + } + + return []*extractor.Inventory{inventory}, nil +} + +type Package struct { + Name string + Version string +} + +func parsePHPFile(r io.Reader) (*Package, error) { + scanner := bufio.NewScanner(r) + var name, version string + + for scanner.Scan() { + line := scanner.Text() + + if strings.Contains(line, "Plugin Name:") { + name = strings.TrimSpace(strings.Split(line, "Plugin Name:")[1]) + } + + if strings.Contains(line, "Version:") { + version = strings.TrimSpace(strings.Split(line, ":")[1]) + } + + if name != "" && version != "" { + break + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read PHP file: %w", err) + } + + if name == "" || version == "" { + return nil, nil + } + + return &Package{Name: name, Version: version}, nil +} + +// ToPURL converts an inventory created by this extractor into a PURL. +func (e Extractor) ToPURL(i *extractor.Inventory) *purl.PackageURL { + return &purl.PackageURL{ + Type: purl.TypeWordpress, + Name: i.Name, + Version: i.Version, + } +} + +// Ecosystem returns the OSV Ecosystem of the software extracted by this extractor. +func (e Extractor) Ecosystem(_ *extractor.Inventory) string { + // wordpress ecosystem does not exist in OSV + return "" +} diff --git a/extractor/filesystem/language/wordpress/plugins/plugins_test.go b/extractor/filesystem/language/wordpress/plugins/plugins_test.go new file mode 100644 index 00000000..b84f0dba --- /dev/null +++ b/extractor/filesystem/language/wordpress/plugins/plugins_test.go @@ -0,0 +1,231 @@ +// 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 plugins_test + +import ( + "context" + "io/fs" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/osv-scalibr/extractor" + "github.com/google/osv-scalibr/extractor/filesystem" + "github.com/google/osv-scalibr/extractor/filesystem/internal/units" + "github.com/google/osv-scalibr/extractor/filesystem/language/wordpress/plugins" + "github.com/google/osv-scalibr/extractor/filesystem/simplefileapi" + "github.com/google/osv-scalibr/purl" + "github.com/google/osv-scalibr/stats" + "github.com/google/osv-scalibr/testing/extracttest" + "github.com/google/osv-scalibr/testing/fakefs" + "github.com/google/osv-scalibr/testing/testcollector" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + cfg plugins.Config + wantCfg plugins.Config + }{ + { + name: "default", + cfg: plugins.DefaultConfig(), + wantCfg: plugins.Config{ + MaxFileSizeBytes: 10 * units.MiB, + }, + }, + { + name: "custom", + cfg: plugins.Config{ + MaxFileSizeBytes: 10, + }, + wantCfg: plugins.Config{ + MaxFileSizeBytes: 10, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := plugins.New(tt.cfg) + if diff := cmp.Diff(tt.wantCfg, got.Config()); diff != "" { + t.Errorf("New(%+v).Config(): (-want +got):\n%s", tt.cfg, diff) + } + }) + } +} + +func TestFileRequired(t *testing.T) { + tests := []struct { + name string + path string + fileSizeBytes int64 + maxFileSizeBytes int64 + wantRequired bool + wantResultMetric stats.FileRequiredResult + }{ + { + name: "wp-content/plugins/foo/test.php file", + path: "wp-content/plugins/foo/test.php", + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "foo/test/bar/wp-content/plugins/foo/test.php file", + path: "foo/test/bar/wp-content/plugins/foo/test.php", + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "file not required", + path: "test.php", + wantRequired: false, + }, + { + name: "wp-content/plugins/foo/test.php file required if file size < max file size", + path: "wp-content/plugins/foo/test.php", + fileSizeBytes: 100 * units.KiB, + maxFileSizeBytes: 1000 * units.KiB, + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "wp-content/plugins/foo/test.php file required if file size == max file size", + path: "wp-content/plugins/foo/test.php", + fileSizeBytes: 1000 * units.KiB, + maxFileSizeBytes: 1000 * units.KiB, + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + { + name: "wp-content/plugins/foo/test.php file not required if file size > max file size", + path: "wp-content/plugins/foo/test.php", + fileSizeBytes: 1000 * units.KiB, + maxFileSizeBytes: 100 * units.KiB, + wantRequired: false, + wantResultMetric: stats.FileRequiredResultSizeLimitExceeded, + }, + { + name: "wp-content/plugins/foo/test.php file required if max file size set to 0", + path: "wp-content/plugins/foo/test.php", + fileSizeBytes: 100 * units.KiB, + maxFileSizeBytes: 0, + wantRequired: true, + wantResultMetric: stats.FileRequiredResultOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + collector := testcollector.New() + var e filesystem.Extractor = plugins.New(plugins.Config{ + Stats: collector, + MaxFileSizeBytes: tt.maxFileSizeBytes, + }) + + fileSizeBytes := tt.fileSizeBytes + if fileSizeBytes == 0 { + fileSizeBytes = 1000 + } + + isRequired := e.FileRequired(simplefileapi.New(tt.path, fakefs.FakeFileInfo{ + FileName: filepath.Base(tt.path), + FileMode: fs.ModePerm, + FileSize: fileSizeBytes, + })) + if isRequired != tt.wantRequired { + t.Fatalf("FileRequired(%s): got %v, want %v", tt.path, isRequired, tt.wantRequired) + } + + gotResultMetric := collector.FileRequiredResult(tt.path) + if tt.wantResultMetric != "" && gotResultMetric != tt.wantResultMetric { + t.Errorf("FileRequired(%s) recorded result metric %v, want result metric %v", tt.path, gotResultMetric, tt.wantResultMetric) + } + }) + } +} + +func TestExtract(t *testing.T) { + tests := []extracttest.TestTableEntry{ + { + Name: "valid plugin file", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/valid", + }, + WantInventory: []*extractor.Inventory{ + { + Name: "Akismet Anti-spam: Spam Protection", + Version: "5.3", + Locations: []string{"testdata/valid"}, + }, + }, + }, + { + Name: "wordpress plugin file not valid", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/invalid", + }, + }, + { + Name: "wordpress plugin file empty", + InputConfig: extracttest.ScanInputMockConfig{ + Path: "testdata/empty", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + collector := testcollector.New() + var e filesystem.Extractor = plugins.New(plugins.Config{ + Stats: collector, + MaxFileSizeBytes: 100, + }) + + scanInput := extracttest.GenerateScanInputMock(t, tt.InputConfig) + defer extracttest.CloseTestScanInput(t, scanInput) + + got, err := e.Extract(context.Background(), &scanInput) + + if diff := cmp.Diff(tt.WantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s.Extract(%q) error diff (-want +got):\n%s", e.Name(), tt.InputConfig.Path, diff) + return + } + + if diff := cmp.Diff(tt.WantInventory, got, cmpopts.SortSlices(extracttest.InventoryCmpLess)); diff != "" { + t.Errorf("%s.Extract(%q) diff (-want +got):\n%s", e.Name(), tt.InputConfig.Path, diff) + } + }) + } +} + +func TestToPURL(t *testing.T) { + e := plugins.Extractor{} + i := &extractor.Inventory{ + Name: "Name", + Version: "1.2.3", + Locations: []string{"location"}, + } + want := &purl.PackageURL{ + Type: purl.TypeWordpress, + Name: "Name", + Version: "1.2.3", + } + got := e.ToPURL(i) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("ToPURL(%v) (-want +got):\n%s", i, diff) + } +} diff --git a/extractor/filesystem/language/wordpress/plugins/testdata/empty b/extractor/filesystem/language/wordpress/plugins/testdata/empty new file mode 100644 index 00000000..e69de29b diff --git a/extractor/filesystem/language/wordpress/plugins/testdata/invalid b/extractor/filesystem/language/wordpress/plugins/testdata/invalid new file mode 100644 index 00000000..347ba93e --- /dev/null +++ b/extractor/filesystem/language/wordpress/plugins/testdata/invalid @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/extractor/filesystem/language/wordpress/plugins/testdata/valid b/extractor/filesystem/language/wordpress/plugins/testdata/valid new file mode 100644 index 00000000..f70330b4 --- /dev/null +++ b/extractor/filesystem/language/wordpress/plugins/testdata/valid @@ -0,0 +1,17 @@ +protect your blog from spam. Akismet Anti-spam keeps your site protected even while you sleep. To get started: activate the Akismet plugin and then go to your Akismet Settings page to set up your API key. +Version: 5.3 +Requires at least: 5.8 +Requires PHP: 5.6.20 +Author: Automattic - Anti-spam Team +Author URI: https://automattic.com/wordpress-plugins/ +License: GPLv2 or later +Text Domain: akismet +*/ +// rest of plugin's code ... \ No newline at end of file diff --git a/extractor/filesystem/list/list.go b/extractor/filesystem/list/list.go index c04c01b6..61ee1605 100644 --- a/extractor/filesystem/list/list.go +++ b/extractor/filesystem/list/list.go @@ -56,6 +56,7 @@ import ( "github.com/google/osv-scalibr/extractor/filesystem/language/ruby/gemfilelock" "github.com/google/osv-scalibr/extractor/filesystem/language/ruby/gemspec" "github.com/google/osv-scalibr/extractor/filesystem/language/rust/cargolock" + "github.com/google/osv-scalibr/extractor/filesystem/language/wordpress/plugins" "github.com/google/osv-scalibr/extractor/filesystem/language/swift/packageresolved" "github.com/google/osv-scalibr/extractor/filesystem/language/swift/podfilelock" "github.com/google/osv-scalibr/extractor/filesystem/os/apk" @@ -143,6 +144,8 @@ var ( // Containers extractors. Containers []filesystem.Extractor = []filesystem.Extractor{containerd.New(containerd.DefaultConfig())} + // Wordpress extractors. + Wordpress []filesystem.Extractor = []filesystem.Extractor{plugins.New(plugins.DefaultConfig())} // OS extractors. OS []filesystem.Extractor = []filesystem.Extractor{ @@ -180,6 +183,7 @@ var ( Ruby, Rust, Dotnet, + Wordpress, SBOM, Swift, OS, @@ -202,6 +206,7 @@ var ( "dotnet": Dotnet, "php": PHP, "rust": Rust, + "wordpress": Wordpress, "swift": Swift, "sbom": SBOM, diff --git a/purl/purl.go b/purl/purl.go index 7ce28582..2a3cb75f 100644 --- a/purl/purl.go +++ b/purl/purl.go @@ -103,6 +103,8 @@ const ( TypeSwift = "swift" // TypeGooget is pkg:googet purl TypeGooget = "googet" + // TypeWordpress is pkg:wordpress purl + TypeWordpress = "wordpress" ) // PackageURL is the struct representation of the parts that make a package url. @@ -199,6 +201,7 @@ func validType(t string) bool { TypeRPM: true, TypeSwift: true, TypeGooget: true, + TypeWordpress: true, } // purl type is case-insensitive, canonical form is lower-case