feat: Add Wordpress cataloger (#2218)

* Closes #1911 Wordpress cataloger

Signed-off-by: disc <a.hacicheant@gmail.com>

* Fixed a few unit tests and static analizer notices

Signed-off-by: disc <a.hacicheant@gmail.com>

* Updated `README.md`

Signed-off-by: disc <a.hacicheant@gmail.com>

* Fixed `golangci-lint` notices
Added integration test for `wordpress-plugin`

Signed-off-by: disc <a.hacicheant@gmail.com>

* Fixed `gosimports` notices

Signed-off-by: disc <a.hacicheant@gmail.com>

* Updated `json schema` version

Signed-off-by: disc <a.hacicheant@gmail.com>

* Fixed CLI tests, increased expected package count

Signed-off-by: disc <a.hacicheant@gmail.com>

* Read first 4Kb of a plugins file's content

Signed-off-by: disc <a.hacicheant@gmail.com>

* replace JSON schema version

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* change wording on source info for wordpress packages

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* Minor changes after a huge refactoring

Signed-off-by: disc <a.hacicheant@gmail.com>

* Removed unused files

Signed-off-by: disc <a.hacicheant@gmail.com>

* Updated schema

Signed-off-by: disc <a.hacicheant@gmail.com>

* Fixed integration tests

Signed-off-by: disc <a.hacicheant@gmail.com>

* fix integration tests

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* Renamed `metadata.Name` to `metadata.PluginInstallDirectory`

Signed-off-by: disc <a.hacicheant@gmail.com>

* rename fields to be compliant with json conventions

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: disc <a.hacicheant@gmail.com>
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
Co-authored-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alexandr Hacicheant
2024-02-14 16:03:25 +00:00
committed by GitHub
co-authored by Alex Goodman
parent 98b700e83c
commit 96ee2db875
30 changed files with 4852 additions and 9 deletions
@@ -145,6 +145,7 @@ func FromPackageAttributes(p pkg.Package) []cpe.CPE {
return result
}
//nolint:funlen
func candidateVendors(p pkg.Package) []string {
// in ecosystems where the packaging metadata does not have a clear field to indicate a vendor (or a field that
// could be interpreted indirectly as such) the project name tends to be a common stand in. Examples of this
@@ -184,6 +185,9 @@ func candidateVendors(p pkg.Package) []string {
vendors.union(candidateVendorsForAPK(p))
case pkg.NpmPackage:
vendors.union(candidateVendorsForJavascript(p))
case pkg.WordpressPluginEntry:
vendors.clear()
vendors.union(candidateVendorsForWordpressPlugin(p))
}
// We should no longer be generating vendor candidates with these values ["" and "*"]
@@ -243,6 +247,11 @@ func candidateProducts(p pkg.Package) []string {
products.union(candidateProductsForAPK(p))
}
if _, hasWordpressMetadata := p.Metadata.(pkg.WordpressPluginEntry); hasWordpressMetadata {
products.clear()
products.union(candidateProductsForWordpressPlugin(p))
}
// it is never OK to have candidates with these values ["" and "*"] (since CPEs will match any other value)
products.removeByValue("")
products.removeByValue("*")
@@ -0,0 +1,56 @@
package cpegenerate
import (
"fmt"
"regexp"
"strings"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/syft/pkg"
)
var (
vendorFromURLRegexp = regexp.MustCompile(`^https?://(www.)?(?P<vendor>.+)\.\w/?`)
)
func candidateVendorsForWordpressPlugin(p pkg.Package) fieldCandidateSet {
metadata, ok := p.Metadata.(pkg.WordpressPluginEntry)
if !ok {
return nil
}
vendors := newFieldCandidateSet()
if metadata.AuthorURI != "" {
matchMap := internal.MatchNamedCaptureGroups(vendorFromURLRegexp, metadata.AuthorURI)
if vendor, ok := matchMap["vendor"]; ok && vendor != "" {
vendors.addValue(vendor)
}
} else {
// add plugin_name + _project as a vendor if no Author URI found
vendors.addValue(fmt.Sprintf("%s_project", normalizeWordpressPluginName(p.Name)))
}
return vendors
}
func candidateProductsForWordpressPlugin(p pkg.Package) fieldCandidateSet {
metadata, ok := p.Metadata.(pkg.WordpressPluginEntry)
if !ok {
return nil
}
products := newFieldCandidateSet()
products.addValue(normalizeWordpressPluginName(p.Name))
products.addValue(normalizeWordpressPluginName(metadata.PluginInstallDirectory))
return products
}
func normalizeWordpressPluginName(name string) string {
name = strings.TrimSpace(strings.ToLower(name))
for _, value := range []string{" "} {
name = strings.ReplaceAll(name, value, "_")
}
return name
}
@@ -0,0 +1,135 @@
package cpegenerate
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/anchore/syft/syft/pkg"
)
func Test_candidateVendorsForWordpressPlugin(t *testing.T) {
tests := []struct {
name string
pkg pkg.Package
expected []string
}{
{
name: "Akismet Anti-spam: Spam Protection",
pkg: pkg.Package{
Name: "Akismet Anti-spam: Spam Protection",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "akismet",
Author: "Automattic - Anti-spam Team",
AuthorURI: "https://automattic.com/wordpress-plugins/",
},
},
expected: []string{"automattic"},
},
{
name: "All-in-One WP Migration",
pkg: pkg.Package{
Name: "All-in-One WP Migration",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "all-in-one-wp-migration",
AuthorURI: "https://servmask.com",
},
},
expected: []string{"servmask"},
},
{
name: "Booking Ultra Pro Appointments Booking Calendar",
pkg: pkg.Package{
Name: "Booking Ultra Pro Appointments Booking Calendar",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "booking-ultra-pro",
Author: "Booking Ultra Pro",
AuthorURI: "https://bookingultrapro.com/",
},
},
expected: []string{"bookingultrapro"},
},
{
name: "Coming Soon Chop Chop",
pkg: pkg.Package{
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "cc-coming-soon",
Author: "Chop-Chop.org",
AuthorURI: "https://www.chop-chop.org",
},
},
expected: []string{"chop-chop"},
},
{
name: "Access Code Feeder",
pkg: pkg.Package{
Name: "Access Code Feeder",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "access-code-feeder",
},
},
// When a plugin as no `Author URI` use plugin_name + _project as a vendor
expected: []string{"access_code_feeder_project"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := candidateVendorsForWordpressPlugin(test.pkg).uniqueValues()
assert.ElementsMatch(t, test.expected, actual, "different vendors")
})
}
}
func Test_candidateProductsWordpressPlugin(t *testing.T) {
tests := []struct {
name string
pkg pkg.Package
expected []string
}{
{
name: "All-in-One WP Migration",
pkg: pkg.Package{
Name: "All-in-One WP Migration",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "all-in-one-wp-migration",
},
},
expected: []string{"all-in-one_wp_migration", "all-in-one-wp-migration"},
},
{
name: "Akismet Anti-spam: Spam Protection",
pkg: pkg.Package{
Name: "Akismet Anti-spam: Spam Protection",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "akismet",
},
},
expected: []string{"akismet_anti-spam:_spam_protection", "akismet"},
},
{
name: "Access Code Feeder",
pkg: pkg.Package{
Name: "Access Code Feeder",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "access-code-feeder",
},
},
expected: []string{"access_code_feeder", "access-code-feeder"},
},
{
name: "CampTix Event Ticketing",
pkg: pkg.Package{
Name: "CampTix Event Ticketing",
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "camptix",
},
},
expected: []string{"camptix_event_ticketing", "camptix"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.ElementsMatch(t, test.expected, candidateProductsForWordpressPlugin(test.pkg).uniqueValues(), "different products")
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package wordpress
import (
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
const (
catalogerName = "wordpress-plugins-cataloger"
wordpressPluginsGlob = "**/wp-content/plugins/*/*.php"
)
func NewWordpressPluginCataloger() pkg.Cataloger {
return generic.NewCataloger(catalogerName).
WithParserByGlobs(parseWordpressPluginFiles, wordpressPluginsGlob)
}
@@ -0,0 +1,33 @@
package wordpress
import (
"testing"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func Test_WordpressPlugin_Globs(t *testing.T) {
tests := []struct {
name string
fixture string
expected []string
}{
{
name: "obtain wordpress plugin files",
fixture: "test-fixtures/glob-paths",
expected: []string{
"wp-content/plugins/akismet/akismet.php",
"wp-content/plugins/all-in-one-wp-migration/all-in-one-wp-migration.php",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
pkgtest.NewCatalogTester().
FromDirectory(t, test.fixture).
ExpectsResolverContentQueries(test.expected).
TestCataloger(t, NewWordpressPluginCataloger())
})
}
}
+31
View File
@@ -0,0 +1,31 @@
package wordpress
import (
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
)
func newWordpressPluginPackage(name, version string, m pluginData, location file.Location) pkg.Package {
meta := pkg.WordpressPluginEntry{
PluginInstallDirectory: m.PluginInstallDirectory,
Author: m.Author,
AuthorURI: m.AuthorURI,
}
p := pkg.Package{
Name: name,
Version: version,
Locations: file.NewLocationSet(location.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation)),
Language: pkg.PHP,
Type: pkg.WordpressPluginPkg,
Metadata: meta,
}
if len(m.Licenses) > 0 {
p.Licenses = pkg.NewLicenseSet(pkg.NewLicense(m.Licenses[0]))
}
p.SetID()
return p
}
@@ -0,0 +1,98 @@
package wordpress
import (
"context"
"fmt"
"path/filepath"
"regexp"
"github.com/anchore/syft/internal"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/generic"
)
const contentBufferSize = 4096
var patterns = map[string]*regexp.Regexp{
// match example: "Plugin Name: WP Migration" ---> WP Migration
"name": regexp.MustCompile(`(?i)plugin name:\s*(?P<name>.+)`),
// match example: "Version: 5.3" ---> 5.3
"version": regexp.MustCompile(`(?i)version:\s*(?P<version>[\d.]+)`),
// match example: "License: GPLv3" ---> GPLv3
"license": regexp.MustCompile(`(?i)license:\s*(?P<license>\w+)`),
// match example: "Author: MonsterInsights" ---> MonsterInsights
"author": regexp.MustCompile(`(?i)author:\s*(?P<author>.+)`),
// match example: "Author URI: https://servmask.com/" ---> https://servmask.com/
"author_uri": regexp.MustCompile(`(?i)author uri:\s*(?P<author_uri>.+)`),
}
type pluginData struct {
Licenses []string `mapstructure:"licenses" json:"licenses,omitempty"`
pkg.WordpressPluginEntry `mapstructure:",squash" json:",inline"`
}
func parseWordpressPluginFiles(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
var pkgs []pkg.Package
var fields = make(map[string]interface{})
buffer := make([]byte, contentBufferSize)
_, err := reader.Read(buffer)
if err != nil {
return nil, nil, fmt.Errorf("failed to read %s file: %w", reader.Location.Path(), err)
}
fileContent := string(buffer)
for field, pattern := range patterns {
matchMap := internal.MatchNamedCaptureGroups(pattern, fileContent)
if value := matchMap[field]; value != "" {
fields[field] = value
}
}
name, nameOk := fields["name"]
version, versionOk := fields["version"]
// get a plugin name from a plugin's directory name
pluginInstallDirectory := filepath.Base(filepath.Dir(reader.RealPath))
if nameOk && name != "" && versionOk && version != "" {
var metadata pluginData
metadata.PluginInstallDirectory = pluginInstallDirectory
author, authorOk := fields["author"]
if authorOk && author != "" {
metadata.Author = author.(string)
}
authorURI, authorURIOk := fields["author_uri"]
if authorURIOk && authorURI != "" {
metadata.AuthorURI = authorURI.(string)
}
license, licenseOk := fields["license"]
if licenseOk && license != "" {
licenses := make([]string, 0)
licenses = append(licenses, license.(string))
metadata.Licenses = licenses
}
pkgs = append(
pkgs,
newWordpressPluginPackage(
name.(string),
version.(string),
metadata,
reader.Location,
),
)
}
return pkgs, nil, nil
}
@@ -0,0 +1,32 @@
package wordpress
import (
"testing"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
)
func TestParseWordpressPluginFiles(t *testing.T) {
fixture := "test-fixtures/glob-paths/wp-content/plugins/akismet/akismet.php"
locations := file.NewLocationSet(file.NewLocation(fixture))
var expectedPkg = pkg.Package{
Name: "Akismet Anti-spam: Spam Protection",
Version: "5.3",
Locations: locations,
Type: pkg.WordpressPluginPkg,
Licenses: pkg.NewLicenseSet(
pkg.NewLicenseFromLocations("GPLv2"),
),
Language: pkg.PHP,
Metadata: pkg.WordpressPluginEntry{
PluginInstallDirectory: "akismet",
Author: "Automattic - Anti-spam Team",
AuthorURI: "https://automattic.com/wordpress-plugins/",
},
}
pkgtest.TestFileParser(t, fixture, parseWordpressPluginFiles, []pkg.Package{expectedPkg}, nil)
}
@@ -0,0 +1,2 @@
<?php
// stub file
@@ -0,0 +1,17 @@
<?php
/**
* @package Akismet
*/
/*
Plugin Name:Akismet Anti-spam: Spam Protection
Plugin URI: https://akismet.com/
Description: Used by millions, Akismet is quite possibly the best way in the world to <strong>protect your blog from spam</strong>. 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 ...
@@ -0,0 +1,10 @@
<?php
/**
* Plugin Name: All-in-One WP Migration
* Plugin URI: https://servmask.com/
* Description: Migration tool for all your blog data. Import or Export your blog content with a single click.
* Author: ServMask
* Author URI: https://servmask.com/
* Version: 7.78
*/
// rest of plugin's code ...
@@ -0,0 +1,2 @@
<?php
// stub file
+6
View File
@@ -40,6 +40,7 @@ const (
RpmPkg Type = "rpm"
RustPkg Type = "rust-crate"
SwiftPkg Type = "swift"
WordpressPluginPkg Type = "wordpress-plugin"
)
// AllPkgs represents all supported package types
@@ -73,6 +74,7 @@ var AllPkgs = []Type{
RpmPkg,
RustPkg,
SwiftPkg,
WordpressPluginPkg,
}
// PackageURLType returns the PURL package type for the current package.
@@ -131,6 +133,8 @@ func (t Type) PackageURLType() string {
return "cargo"
case SwiftPkg:
return packageurl.TypeSwift
case WordpressPluginPkg:
return "wordpress-plugin"
default:
// TODO: should this be a "generic" purl type instead?
return ""
@@ -201,6 +205,8 @@ func TypeByName(name string) Type {
return Rpkg
case packageurl.TypeSwift:
return SwiftPkg
case "wordpress-plugin":
return WordpressPluginPkg
default:
return UnknownPkg
}
+1
View File
@@ -119,6 +119,7 @@ func TestTypeFromPURL(t *testing.T) {
expectedTypes.Remove(string(BinaryPkg))
expectedTypes.Remove(string(LinuxKernelModulePkg))
expectedTypes.Remove(string(GithubActionPkg), string(GithubActionWorkflowPkg))
expectedTypes.Remove(string(WordpressPluginPkg))
for _, test := range tests {
t.Run(string(test.expected), func(t *testing.T) {
+8
View File
@@ -0,0 +1,8 @@
package pkg
// WordpressPluginEntry represents all metadata parsed from the wordpress plugin file
type WordpressPluginEntry struct {
PluginInstallDirectory string `mapstructure:"pluginInstallDirectory" json:"pluginInstallDirectory"`
Author string `mapstructure:"author" json:"author,omitempty"`
AuthorURI string `mapstructure:"authorUri" json:"authorUri,omitempty"`
}