mirror of
https://github.com/anchore/syft.git
synced 2026-11-02 07:26:34 +01:00
feat: Add R cataloger (#1790)
Add a cataloger that detects installed R packages by looking for DESCRIPTION files. The base R package is now picked up in coverageImage tests in test/cli/packages_cmd_test.go, so increment expected package counts for the tests that use that image. Signed-off-by: Will Murphy <will.murphy@anchore.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/php"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/portage"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/python"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/r"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/rpm"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/ruby"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/rust"
|
||||
@@ -53,6 +54,7 @@ func ImageCatalogers(cfg Config) []pkg.Cataloger {
|
||||
php.NewComposerInstalledCataloger(),
|
||||
portage.NewPortageCataloger(),
|
||||
python.NewPythonPackageCataloger(),
|
||||
r.NewPackageCataloger(),
|
||||
rpm.NewRpmDBCataloger(),
|
||||
ruby.NewGemSpecCataloger(),
|
||||
sbom.NewSBOMCataloger(),
|
||||
@@ -121,6 +123,7 @@ func AllCatalogers(cfg Config) []pkg.Cataloger {
|
||||
portage.NewPortageCataloger(),
|
||||
python.NewPythonIndexCataloger(),
|
||||
python.NewPythonPackageCataloger(),
|
||||
r.NewPackageCataloger(),
|
||||
rpm.NewFileCataloger(),
|
||||
rpm.NewRpmDBCataloger(),
|
||||
ruby.NewGemFileLockCataloger(),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package r
|
||||
|
||||
import (
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
)
|
||||
|
||||
const catalogerName = "r-package-cataloger"
|
||||
|
||||
// NewPackageCataloger returns a new R cataloger object based on detection of R package DESCRIPTION files.
|
||||
func NewPackageCataloger() *generic.Cataloger {
|
||||
return generic.NewCataloger(catalogerName).
|
||||
WithParserByGlobs(parseDescriptionFile, "**/DESCRIPTION")
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package r
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func TestRPackageCataloger(t *testing.T) {
|
||||
expectedPkgs := []pkg.Package{
|
||||
{
|
||||
Name: "base",
|
||||
Version: "4.3.0",
|
||||
FoundBy: "r-package-cataloger",
|
||||
Locations: source.NewLocationSet(source.NewLocation("base/DESCRIPTION")),
|
||||
Licenses: []string{"Part of R 4.3.0"},
|
||||
Language: pkg.R,
|
||||
Type: pkg.Rpkg,
|
||||
PURL: "pkg:cran/base@4.3.0",
|
||||
MetadataType: pkg.RDescriptionFileMetadataType,
|
||||
Metadata: pkg.RDescriptionFileMetadata{
|
||||
Title: "The R Base Package",
|
||||
Description: "Base R functions.",
|
||||
Author: "R Core Team and contributors worldwide",
|
||||
Maintainer: "R Core Team <do-use-Contact-address@r-project.org>",
|
||||
Built: "R 4.3.0; ; 2023-04-21 11:33:09 UTC; unix",
|
||||
Suggests: []string{"methods"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "stringr",
|
||||
Version: "1.5.0.9000",
|
||||
FoundBy: "r-package-cataloger",
|
||||
Locations: source.NewLocationSet(source.NewLocation("stringr/DESCRIPTION")),
|
||||
Licenses: []string{"MIT + file LICENSE"},
|
||||
Language: pkg.R,
|
||||
Type: pkg.Rpkg,
|
||||
PURL: "pkg:cran/stringr@1.5.0.9000",
|
||||
MetadataType: pkg.RDescriptionFileMetadataType,
|
||||
Metadata: pkg.RDescriptionFileMetadata{
|
||||
Title: "Simple, Consistent Wrappers for Common String Operations",
|
||||
Description: "A consistent, simple and easy to use set of wrappers around the fantastic 'stringi' package. All function and argument names (and positions) are consistent, all functions deal with \"NA\"'s and zero length vectors in the same way, and the output from one function is easy to feed into the input of another.",
|
||||
URL: []string{"https://stringr.tidyverse.org", "https://github.com/tidyverse/stringr"},
|
||||
Imports: []string{
|
||||
"cli", "glue (>= 1.6.1)", "lifecycle (>= 1.0.3)", "magrittr",
|
||||
"rlang (>= 1.0.0)", "stringi (>= 1.5.3)", "vctrs (>= 0.4.0)",
|
||||
},
|
||||
Depends: []string{"R (>= 3.3)"},
|
||||
Suggests: []string{"covr", "dplyr", "gt", "htmltools", "htmlwidgets", "knitr", "rmarkdown", "testthat (>= 3.0.0)", "tibble"},
|
||||
},
|
||||
},
|
||||
}
|
||||
// TODO: relationships are not under test yet
|
||||
var expectedRelationships []artifact.Relationship
|
||||
|
||||
pkgtest.NewCatalogTester().FromDirectory(t, "test-fixtures/installed").Expects(expectedPkgs, expectedRelationships).TestCataloger(t, NewPackageCataloger())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package r
|
||||
|
||||
import (
|
||||
"github.com/anchore/packageurl-go"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func newPackage(pd parseData, locations ...source.Location) pkg.Package {
|
||||
locationSet := source.NewLocationSet()
|
||||
for _, loc := range locations {
|
||||
locationSet.Add(loc.WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation))
|
||||
}
|
||||
result := pkg.Package{
|
||||
Name: pd.Package,
|
||||
Version: pd.Version,
|
||||
Locations: locationSet,
|
||||
Licenses: []string{pd.License},
|
||||
Language: pkg.R,
|
||||
Type: pkg.Rpkg,
|
||||
PURL: packageURL(pd),
|
||||
MetadataType: pkg.RDescriptionFileMetadataType,
|
||||
Metadata: pd.RDescriptionFileMetadata,
|
||||
}
|
||||
|
||||
result.SetID()
|
||||
return result
|
||||
}
|
||||
|
||||
func packageURL(m parseData) string {
|
||||
return packageurl.NewPackageURL("cran", "", m.Package, m.Version, nil, "").ToString()
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package r
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_newPackage(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
}{}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package r
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/syft/artifact"
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/pkg/cataloger/generic"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
/* some examples of license strings found in DESCRIPTION files:
|
||||
find /usr/local/lib/R -name DESCRIPTION | xargs cat | grep 'License:' | sort | uniq
|
||||
License: GPL
|
||||
License: GPL (>= 2)
|
||||
License: GPL (>=2)
|
||||
License: GPL(>=2)
|
||||
License: GPL (>= 2) | file LICENCE
|
||||
License: GPL-2 | GPL-3
|
||||
License: GPL-3
|
||||
License: LGPL (>= 2)
|
||||
License: LGPL (>= 2.1)
|
||||
License: MIT + file LICENSE
|
||||
License: Part of R 4.3.0
|
||||
License: Unlimited
|
||||
*/
|
||||
|
||||
func parseDescriptionFile(_ source.FileResolver, _ *generic.Environment, reader source.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
values := extractFieldsFromDescriptionFile(reader)
|
||||
m := parseDataFromDescriptionMap(values)
|
||||
p := newPackage(m, []source.Location{reader.Location}...)
|
||||
if p.Name == "" || p.Version == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return []pkg.Package{p}, nil, nil
|
||||
}
|
||||
|
||||
type parseData struct {
|
||||
Package string
|
||||
Version string
|
||||
License string
|
||||
pkg.RDescriptionFileMetadata
|
||||
}
|
||||
|
||||
func parseDataFromDescriptionMap(values map[string]string) parseData {
|
||||
return parseData{
|
||||
License: values["License"],
|
||||
Package: values["Package"],
|
||||
Version: values["Version"],
|
||||
RDescriptionFileMetadata: pkg.RDescriptionFileMetadata{
|
||||
Title: values["Title"],
|
||||
Description: cleanMultiLineValue(values["Description"]),
|
||||
Maintainer: values["Maintainer"],
|
||||
URL: commaSeparatedList(values["URL"]),
|
||||
Depends: commaSeparatedList(values["Depends"]),
|
||||
Imports: commaSeparatedList(values["Imports"]),
|
||||
Suggests: commaSeparatedList(values["Suggests"]),
|
||||
NeedsCompilation: yesNoToBool(values["NeedsCompilation"]),
|
||||
Author: values["Author"],
|
||||
Repository: values["Repository"],
|
||||
Built: values["Built"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func yesNoToBool(s string) bool {
|
||||
/*
|
||||
$ docker run --rm -it rocker/r-ver bash
|
||||
$ install2.r ggplot2 dplyr mlr3 caret # just some packages for a larger sample
|
||||
$ find /usr/local/lib/R -name DESCRIPTION | xargs cat | grep 'NeedsCompilation:' | sort | uniq
|
||||
NeedsCompilation: no
|
||||
NeedsCompilation: yes
|
||||
$ find /usr/local/lib/R -name DESCRIPTION | xargs cat | grep 'NeedsCompilation:' | wc -l
|
||||
105
|
||||
*/
|
||||
return strings.EqualFold(s, "yes")
|
||||
}
|
||||
|
||||
func commaSeparatedList(s string) []string {
|
||||
var result []string
|
||||
split := strings.Split(s, ",")
|
||||
for _, piece := range split {
|
||||
value := strings.TrimSpace(piece)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var space = regexp.MustCompile(`\s+`)
|
||||
|
||||
func cleanMultiLineValue(s string) string {
|
||||
return space.ReplaceAllString(s, " ")
|
||||
}
|
||||
|
||||
func extractFieldsFromDescriptionFile(reader io.Reader) map[string]string {
|
||||
result := make(map[string]string)
|
||||
key := ""
|
||||
var valueFragment strings.Builder
|
||||
scanner := bufio.NewScanner(reader)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// line is like Key: Value -> start capturing value; close out previous value
|
||||
// line is like \t\t continued value -> append to existing value
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if startsWithWhitespace(line) {
|
||||
// we're continuing a value
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
valueFragment.WriteByte('\n')
|
||||
valueFragment.WriteString(strings.TrimSpace(line))
|
||||
} else {
|
||||
if key != "" {
|
||||
// capture previous value
|
||||
result[key] = valueFragment.String()
|
||||
key = ""
|
||||
valueFragment = strings.Builder{}
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key = parts[0]
|
||||
valueFragment.WriteString(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
}
|
||||
if key != "" {
|
||||
result[key] = valueFragment.String()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func startsWithWhitespace(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
return s[0] == ' ' || s[0] == '\t'
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package r
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/anchore/syft/syft/pkg"
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
func Test_parseDescriptionFile(t *testing.T) {
|
||||
type packageAssertions []func(*testing.T, []pkg.Package)
|
||||
tests := []struct {
|
||||
name string
|
||||
assertions packageAssertions
|
||||
fixture string
|
||||
}{
|
||||
{
|
||||
name: "no package is returned if no version found",
|
||||
fixture: filepath.Join("test-fixtures", "map-parse", "no-version"),
|
||||
assertions: packageAssertions{
|
||||
func(t *testing.T, p []pkg.Package) {
|
||||
assert.Empty(t, p)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no package is returned if no package name found",
|
||||
fixture: filepath.Join("test-fixtures", "map-parse", "no-name"),
|
||||
assertions: packageAssertions{
|
||||
func(t *testing.T, p []pkg.Package) {
|
||||
assert.Empty(t, p)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "package return if both name and version found",
|
||||
fixture: filepath.Join("test-fixtures", "map-parse", "simple"),
|
||||
assertions: packageAssertions{
|
||||
func(t *testing.T, p []pkg.Package) {
|
||||
assert.Equal(t, 1, len(p))
|
||||
assert.Equal(t, "base", p[0].Name)
|
||||
assert.Equal(t, "4.3.0", p[0].Version)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, err := os.Open(tt.fixture)
|
||||
input := source.LocationReadCloser{
|
||||
Location: source.NewLocation(tt.fixture),
|
||||
ReadCloser: f,
|
||||
}
|
||||
got, _, err := parseDescriptionFile(nil, nil, input)
|
||||
assert.NoError(t, err)
|
||||
for _, assertion := range tt.assertions {
|
||||
assertion(t, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_extractFieldsFromDescriptionFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fixture string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "go case",
|
||||
fixture: "test-fixtures/map-parse/simple",
|
||||
want: map[string]string{
|
||||
"Package": "base",
|
||||
"Version": "4.3.0",
|
||||
"Suggests": "methods",
|
||||
"Built": "R 4.3.0; ; 2023-04-21 11:33:09 UTC; unix",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bad cases",
|
||||
fixture: "test-fixtures/map-parse/bad",
|
||||
want: map[string]string{
|
||||
"Key": "",
|
||||
"Whitespace": "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiline key-value",
|
||||
fixture: "test-fixtures/map-parse/multiline",
|
||||
want: map[string]string{
|
||||
"Description": `A consistent, simple and easy to use set of wrappers around
|
||||
the fantastic 'stringi' package. All function and argument names (and
|
||||
positions) are consistent, all functions deal with "NA"'s and zero
|
||||
length vectors in the same way, and the output from one function is
|
||||
easy to feed into the input of another.`,
|
||||
"License": "MIT + file LICENSE",
|
||||
"Key": "value",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "eof multiline",
|
||||
fixture: "test-fixtures/map-parse/eof-multiline",
|
||||
want: map[string]string{
|
||||
"License": "MIT + file LICENSE",
|
||||
"Description": `A consistent, simple and easy to use set of wrappers around
|
||||
the fantastic 'stringi' package. All function and argument names (and
|
||||
positions) are consistent, all functions deal with "NA"'s and zero
|
||||
length vectors in the same way, and the output from one function is
|
||||
easy to feed into the input of another.`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
file, err := os.Open(test.fixture)
|
||||
require.NoError(t, err)
|
||||
|
||||
result := extractFieldsFromDescriptionFile(file)
|
||||
|
||||
assert.Equal(t, test.want, result)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
Package: stringr
|
||||
Title: Simple, Consistent Wrappers for Common String Operations
|
||||
Version: 1.5.0.9000
|
||||
Authors@R:
|
||||
c(person(given = "Hadley",
|
||||
family = "Wickham",
|
||||
role = c("aut", "cre", "cph"),
|
||||
email = "hadley@rstudio.com"),
|
||||
person(given = "RStudio",
|
||||
role = c("cph", "fnd")))
|
||||
Description: A consistent, simple and easy to use set of wrappers around
|
||||
the fantastic 'stringi' package. All function and argument names (and
|
||||
positions) are consistent, all functions deal with "NA"'s and zero
|
||||
length vectors in the same way, and the output from one function is
|
||||
easy to feed into the input of another.
|
||||
License: MIT + file LICENSE
|
||||
URL: https://stringr.tidyverse.org, https://github.com/tidyverse/stringr
|
||||
BugReports: https://github.com/tidyverse/stringr/issues
|
||||
Depends:
|
||||
R (>= 3.3)
|
||||
Imports:
|
||||
cli,
|
||||
glue (>= 1.6.1),
|
||||
lifecycle (>= 1.0.3),
|
||||
magrittr,
|
||||
rlang (>= 1.0.0),
|
||||
stringi (>= 1.5.3),
|
||||
vctrs (>= 0.4.0)
|
||||
Suggests:
|
||||
covr,
|
||||
dplyr,
|
||||
gt,
|
||||
htmltools,
|
||||
htmlwidgets,
|
||||
knitr,
|
||||
rmarkdown,
|
||||
testthat (>= 3.0.0),
|
||||
tibble
|
||||
VignetteBuilder:
|
||||
knitr
|
||||
Config/Needs/website: tidyverse/tidytemplate
|
||||
Config/testthat/edition: 3
|
||||
Encoding: UTF-8
|
||||
LazyData: true
|
||||
Roxygen: list(markdown = TRUE)
|
||||
RoxygenNote: 7.2.1
|
||||
@@ -0,0 +1,11 @@
|
||||
Package: base
|
||||
Version: 4.3.0
|
||||
Priority: base
|
||||
Title: The R Base Package
|
||||
Author: R Core Team and contributors worldwide
|
||||
Maintainer: R Core Team <do-use-Contact-address@r-project.org>
|
||||
Contact: R-help mailing list <r-help@r-project.org>
|
||||
Description: Base R functions.
|
||||
License: Part of R 4.3.0
|
||||
Suggests: methods
|
||||
Built: R 4.3.0; ; 2023-04-21 11:33:09 UTC; unix
|
||||
@@ -0,0 +1,46 @@
|
||||
Package: stringr
|
||||
Title: Simple, Consistent Wrappers for Common String Operations
|
||||
Version: 1.5.0.9000
|
||||
Authors@R:
|
||||
c(person(given = "Hadley",
|
||||
family = "Wickham",
|
||||
role = c("aut", "cre", "cph"),
|
||||
email = "hadley@rstudio.com"),
|
||||
person(given = "RStudio",
|
||||
role = c("cph", "fnd")))
|
||||
Description: A consistent, simple and easy to use set of wrappers around
|
||||
the fantastic 'stringi' package. All function and argument names (and
|
||||
positions) are consistent, all functions deal with "NA"'s and zero
|
||||
length vectors in the same way, and the output from one function is
|
||||
easy to feed into the input of another.
|
||||
License: MIT + file LICENSE
|
||||
URL: https://stringr.tidyverse.org, https://github.com/tidyverse/stringr
|
||||
BugReports: https://github.com/tidyverse/stringr/issues
|
||||
Depends:
|
||||
R (>= 3.3)
|
||||
Imports:
|
||||
cli,
|
||||
glue (>= 1.6.1),
|
||||
lifecycle (>= 1.0.3),
|
||||
magrittr,
|
||||
rlang (>= 1.0.0),
|
||||
stringi (>= 1.5.3),
|
||||
vctrs (>= 0.4.0)
|
||||
Suggests:
|
||||
covr,
|
||||
dplyr,
|
||||
gt,
|
||||
htmltools,
|
||||
htmlwidgets,
|
||||
knitr,
|
||||
rmarkdown,
|
||||
testthat (>= 3.0.0),
|
||||
tibble
|
||||
VignetteBuilder:
|
||||
knitr
|
||||
Config/Needs/website: tidyverse/tidytemplate
|
||||
Config/testthat/edition: 3
|
||||
Encoding: UTF-8
|
||||
LazyData: true
|
||||
Roxygen: list(markdown = TRUE)
|
||||
RoxygenNote: 7.2.1
|
||||
@@ -0,0 +1,3 @@
|
||||
MissingColon
|
||||
Whitespace:
|
||||
Key:
|
||||
@@ -0,0 +1,6 @@
|
||||
License: MIT + file LICENSE
|
||||
Description: A consistent, simple and easy to use set of wrappers around
|
||||
the fantastic 'stringi' package. All function and argument names (and
|
||||
positions) are consistent, all functions deal with "NA"'s and zero
|
||||
length vectors in the same way, and the output from one function is
|
||||
easy to feed into the input of another.
|
||||
@@ -0,0 +1,8 @@
|
||||
Key: value
|
||||
|
||||
Description: A consistent, simple and easy to use set of wrappers around
|
||||
the fantastic 'stringi' package. All function and argument names (and
|
||||
positions) are consistent, all functions deal with "NA"'s and zero
|
||||
length vectors in the same way, and the output from one function is
|
||||
easy to feed into the input of another.
|
||||
License: MIT + file LICENSE
|
||||
@@ -0,0 +1,2 @@
|
||||
Version: 1.2.3
|
||||
Description: a package with no name
|
||||
@@ -0,0 +1 @@
|
||||
Package: foo
|
||||
@@ -0,0 +1,4 @@
|
||||
Package: base
|
||||
Version: 4.3.0
|
||||
Suggests: methods
|
||||
Built: R 4.3.0; ; 2023-04-21 11:33:09 UTC; unix
|
||||
Reference in New Issue
Block a user