diff --git a/README.md b/README.md index 6a709b9..78faae4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,20 @@ reg := enrichment.NewRegistriesClient() // direct registry queries only dep := enrichment.NewDepsDevClient() // deps.dev API only ``` +## Vulnerabilities, Licenses, and Versions + +```go +vulns, err := enrichment.CheckVulnerabilities(ctx, "npm", "lodash", "4.17.20") +for _, vuln := range vulns { + fmt.Printf("%s: %s fixed in %s\n", vuln.ID, vuln.Severity, vuln.FixedVersion) +} + +category := enrichment.CategorizeLicense("MIT OR Apache-2.0") // permissive +outdated := enrichment.IsOutdated("1.0.0", "1.2.0") // true +``` + +Vulnerability checks use OSV by default. License categorization and version comparison are local helpers, so they continue to work in direct/private registry environments. + ## Scorecard The `scorecard` sub-package queries the [OpenSSF Scorecard](https://securityscorecards.dev) API for repository-level security scores. diff --git a/client.go b/client.go index 4098424..1fae641 100644 --- a/client.go +++ b/client.go @@ -61,7 +61,7 @@ type Maintainer struct { // Advisory is a security advisory affecting a package. type Advisory struct { Title string - Severity string // e.g. "critical", "high", "medium", "low" + Severity string // e.g. "critical", "high", "medium", "low" CVSSScore float32 URL string Identifiers []string // CVE IDs and other identifiers @@ -73,4 +73,5 @@ type VersionInfo struct { PublishedAt time.Time Integrity string License string + Yanked bool } diff --git a/go.mod b/go.mod index 7b848f2..b59d23a 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,18 @@ require ( github.com/ecosyste-ms/ecosystems-go v0.1.1 github.com/git-pkgs/purl v0.1.12 github.com/git-pkgs/registries v0.6.1 + github.com/git-pkgs/spdx v0.1.4 github.com/git-pkgs/vers v0.2.6 + github.com/git-pkgs/vulns v0.1.5 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/git-pkgs/packageurl-go v0.3.1 // indirect github.com/git-pkgs/pom v0.1.4 // indirect - github.com/git-pkgs/spdx v0.1.4 // indirect github.com/github/go-spdx/v2 v2.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/package-url/packageurl-go v0.1.6 // indirect + github.com/pandatix/go-cvss v0.6.2 // indirect ) diff --git a/go.sum b/go.sum index 9a85af4..baf21ef 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ github.com/git-pkgs/spdx v0.1.4 h1:eQ0waEV3uUeItpWAOvdN1K1rL9hTgsU7fF74r1mDXMs= github.com/git-pkgs/spdx v0.1.4/go.mod h1:cqRoZcvl530s/W+oGNvwjt4ODN8T1W6D/20MUZEFdto= github.com/git-pkgs/vers v0.2.6 h1:IelZd7BP/JhzTloUTDY67nehUgoYva3g9viqAMCHJg8= github.com/git-pkgs/vers v0.2.6/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= +github.com/git-pkgs/vulns v0.1.5 h1:mtX88/27toFl+B95kaH5QbAdOCQ3YIDGjJrlrrnqQTE= +github.com/git-pkgs/vulns v0.1.5/go.mod h1:bZFikfrR/5gC0ZMwXh7qcEu2gpKfXMBhVsy4kF12Ae0= github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -28,6 +30,8 @@ github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQ github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/package-url/packageurl-go v0.1.6 h1:YO3p6u1XmCUliivUg/qWphaY8vI6hxSnnPv7Bfg3m5M= github.com/package-url/packageurl-go v0.1.6/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= +github.com/pandatix/go-cvss v0.6.2 h1:TFiHlzUkT67s6UkelHmK6s1INKVUG7nlKYiWWDTITGI= +github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..522ed73 --- /dev/null +++ b/helpers.go @@ -0,0 +1,51 @@ +package enrichment + +import ( + "strings" + + "github.com/git-pkgs/spdx" + "github.com/git-pkgs/vers" +) + +// LicenseCategory describes the broad policy category for a license expression. +type LicenseCategory string + +const ( + // LicenseCategoryPermissive is used when every license in the expression is permissive. + LicenseCategoryPermissive LicenseCategory = "permissive" + // LicenseCategoryCopyleft is used when the expression contains a copyleft license. + LicenseCategoryCopyleft LicenseCategory = "copyleft" + // LicenseCategoryUnknown is used when the expression cannot be classified. + LicenseCategoryUnknown LicenseCategory = "unknown" +) + +// CategorizeLicense classifies a license expression as permissive, copyleft, or unknown. +func CategorizeLicense(license string) LicenseCategory { + license = strings.TrimSpace(license) + if license == "" { + return LicenseCategoryUnknown + } + + normalized, err := spdx.NormalizeExpressionLax(license) + if err != nil { + return LicenseCategoryUnknown + } + + if spdx.HasCopyleft(normalized) { + return LicenseCategoryCopyleft + } + if spdx.IsFullyPermissive(normalized) { + return LicenseCategoryPermissive + } + return LicenseCategoryUnknown +} + +// IsOutdated reports whether current is older than latest. +func IsOutdated(current, latest string) bool { + current = strings.TrimSpace(current) + latest = strings.TrimSpace(latest) + if current == "" || latest == "" { + return false + } + return vers.Compare(current, latest) < 0 +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..3cd7096 --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,50 @@ +package enrichment + +import "testing" + +func TestCategorizeLicense(t *testing.T) { + tests := []struct { + name string + license string + want LicenseCategory + }{ + {"permissive identifier", "MIT", LicenseCategoryPermissive}, + {"permissive expression", "MIT OR Apache-2.0", LicenseCategoryPermissive}, + {"informal permissive", "MIT License", LicenseCategoryPermissive}, + {"copyleft identifier", "GPL-3.0-only", LicenseCategoryCopyleft}, + {"copyleft expression", "MIT AND GPL-2.0-only", LicenseCategoryCopyleft}, + {"empty", "", LicenseCategoryUnknown}, + {"invalid", "not-a-license", LicenseCategoryUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CategorizeLicense(tt.license); got != tt.want { + t.Errorf("CategorizeLicense(%q) = %q, want %q", tt.license, got, tt.want) + } + }) + } +} + +func TestIsOutdated(t *testing.T) { + tests := []struct { + current string + latest string + want bool + }{ + {"1.0.0", "1.0.1", true}, + {"1.0.1", "1.0.1", false}, + {"1.0.2", "1.0.1", false}, + {"", "1.0.1", false}, + {"1.0.0", "", false}, + {" v1.0.0 ", "1.0.1", true}, + } + + for _, tt := range tests { + t.Run(tt.current+"_"+tt.latest, func(t *testing.T) { + if got := IsOutdated(tt.current, tt.latest); got != tt.want { + t.Errorf("IsOutdated(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want) + } + }) + } +} diff --git a/registries.go b/registries.go index 64a1b74..0afeca1 100644 --- a/registries.go +++ b/registries.go @@ -133,6 +133,7 @@ func (c *RegistriesClient) GetVersions(ctx context.Context, purlStr string) ([]V PublishedAt: v.PublishedAt, Integrity: v.Integrity, License: v.Licenses, + Yanked: v.Status == registries.StatusYanked, } result = append(result, info) } @@ -153,6 +154,7 @@ func (c *RegistriesClient) GetVersion(ctx context.Context, purlStr string) (*Ver PublishedAt: v.PublishedAt, Integrity: v.Integrity, License: v.Licenses, + Yanked: v.Status == registries.StatusYanked, }, nil } diff --git a/vulnerabilities.go b/vulnerabilities.go new file mode 100644 index 0000000..64e032e --- /dev/null +++ b/vulnerabilities.go @@ -0,0 +1,190 @@ +package enrichment + +import ( + "context" + "fmt" + "strings" + + "github.com/git-pkgs/purl" + "github.com/git-pkgs/vulns" + "github.com/git-pkgs/vulns/osv" +) + +// VulnerabilityQuery identifies a package version to check for vulnerabilities. +type VulnerabilityQuery struct { + Ecosystem string + Name string + Version string +} + +// VulnerabilityResult contains the vulnerabilities found for a query. +type VulnerabilityResult struct { + Query VulnerabilityQuery + Vulnerabilities []VulnInfo +} + +// VulnInfo contains the vulnerability fields most consumers need for display and policy checks. +type VulnInfo struct { + ID string + Summary string + Details string + Severity string + CVSSScore float64 + CVSSVersion string + CVSSVector string + FixedVersion string + References []string + Aliases []string + Source string +} + +// VulnerabilityClient checks package vulnerabilities using a configured source. +type VulnerabilityClient struct { + source vulns.Source +} + +// VulnerabilityOption configures a VulnerabilityClient. +type VulnerabilityOption func(*vulnerabilityOptions) + +type vulnerabilityOptions struct { + source vulns.Source + userAgent string +} + +// WithVulnerabilitySource sets the vulnerability data source. +func WithVulnerabilitySource(source vulns.Source) VulnerabilityOption { + return func(o *vulnerabilityOptions) { + o.source = source + } +} + +// WithVulnerabilityUserAgent sets the User-Agent for the default OSV source. +func WithVulnerabilityUserAgent(userAgent string) VulnerabilityOption { + return func(o *vulnerabilityOptions) { + o.userAgent = userAgent + } +} + +// NewVulnerabilityClient creates a client backed by OSV unless another source is provided. +func NewVulnerabilityClient(opts ...VulnerabilityOption) *VulnerabilityClient { + o := vulnerabilityOptions{userAgent: defaultUserAgent} + for _, opt := range opts { + opt(&o) + } + if o.source == nil { + o.source = osv.New(osv.WithUserAgent(o.userAgent)) + } + return &VulnerabilityClient{source: o.source} +} + +// CheckVulnerabilities checks one package version using the default OSV-backed client. +func CheckVulnerabilities(ctx context.Context, ecosystem, name, version string) ([]VulnInfo, error) { + return NewVulnerabilityClient().Check(ctx, ecosystem, name, version) +} + +// BulkCheckVulnerabilities checks multiple package versions using the default OSV-backed client. +func BulkCheckVulnerabilities(ctx context.Context, queries []VulnerabilityQuery) ([]VulnerabilityResult, error) { + return NewVulnerabilityClient().CheckBatch(ctx, queries) +} + +// Check checks one package version for known vulnerabilities. +func (c *VulnerabilityClient) Check(ctx context.Context, ecosystem, name, version string) ([]VulnInfo, error) { + p, err := vulnerabilityPURL(ecosystem, name, version) + if err != nil { + return nil, err + } + + found, err := c.source.Query(ctx, p) + if err != nil { + return nil, err + } + return convertVulnerabilities(found, p, c.source.Name()), nil +} + +// CheckBatch checks multiple package versions for known vulnerabilities. +func (c *VulnerabilityClient) CheckBatch(ctx context.Context, queries []VulnerabilityQuery) ([]VulnerabilityResult, error) { + if len(queries) == 0 { + return nil, nil + } + + purls := make([]*purl.PURL, len(queries)) + for i, query := range queries { + p, err := vulnerabilityPURL(query.Ecosystem, query.Name, query.Version) + if err != nil { + return nil, err + } + purls[i] = p + } + + found, err := c.source.QueryBatch(ctx, purls) + if err != nil { + return nil, err + } + if len(found) != len(purls) { + return nil, fmt.Errorf("vulnerability source returned %d results for %d queries", len(found), len(purls)) + } + + results := make([]VulnerabilityResult, len(queries)) + for i, vulnsForPackage := range found { + results[i] = VulnerabilityResult{ + Query: queries[i], + Vulnerabilities: convertVulnerabilities(vulnsForPackage, purls[i], c.source.Name()), + } + } + return results, nil +} + +func vulnerabilityPURL(ecosystem, name, version string) (*purl.PURL, error) { + ecosystem = strings.TrimSpace(ecosystem) + name = strings.TrimSpace(name) + version = strings.TrimSpace(version) + + purlType := purl.EcosystemToPURLType(ecosystem) + if purlType == "" { + purlType = purl.NormalizeEcosystem(ecosystem) + } + if purlType == "" || name == "" { + return nil, fmt.Errorf("ecosystem and name are required") + } + return purl.MakePURL(purlType, name, version), nil +} + +func convertVulnerabilities(found []vulns.Vulnerability, p *purl.PURL, source string) []VulnInfo { + if len(found) == 0 { + return nil + } + + infos := make([]VulnInfo, 0, len(found)) + for _, v := range found { + info := VulnInfo{ + ID: v.ID, + Summary: v.Summary, + Details: v.Details, + Severity: v.SeverityLevel(), + FixedVersion: v.FixedVersion(p.Type, p.FullName()), + References: referenceURLs(v.References), + Aliases: v.Aliases, + Source: source, + } + if cvss := v.CVSS(); cvss != nil { + info.CVSSScore = cvss.Score + info.CVSSVersion = cvss.Version + info.CVSSVector = cvss.Vector + } + infos = append(infos, info) + } + return infos +} + +func referenceURLs(refs []vulns.Reference) []string { + if len(refs) == 0 { + return nil + } + urls := make([]string, 0, len(refs)) + for _, ref := range refs { + if ref.URL != "" { + urls = append(urls, ref.URL) + } + } + return urls +} diff --git a/vulnerabilities_test.go b/vulnerabilities_test.go new file mode 100644 index 0000000..8b414dc --- /dev/null +++ b/vulnerabilities_test.go @@ -0,0 +1,152 @@ +package enrichment + +import ( + "context" + "errors" + "testing" + + "github.com/git-pkgs/purl" + "github.com/git-pkgs/vulns" +) + +type fakeVulnerabilitySource struct { + queryResult []vulns.Vulnerability + queryBatchResult [][]vulns.Vulnerability + err error +} + +func (s *fakeVulnerabilitySource) Name() string { + return "fake" +} + +func (s *fakeVulnerabilitySource) Query(context.Context, *purl.PURL) ([]vulns.Vulnerability, error) { + return s.queryResult, s.err +} + +func (s *fakeVulnerabilitySource) QueryBatch(context.Context, []*purl.PURL) ([][]vulns.Vulnerability, error) { + return s.queryBatchResult, s.err +} + +func (s *fakeVulnerabilitySource) Get(context.Context, string) (*vulns.Vulnerability, error) { + return nil, nil +} + +func TestVulnerabilityClientCheck(t *testing.T) { + source := &fakeVulnerabilitySource{ + queryResult: []vulns.Vulnerability{ + { + ID: "GHSA-test", + Summary: "test summary", + Details: "test details", + Aliases: []string{"CVE-2024-0001"}, + Severity: []vulns.Severity{ + {Type: "CVSS_V3", Score: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N"}, + }, + References: []vulns.Reference{ + {Type: "ADVISORY", URL: "https://example.com/advisory"}, + {Type: "WEB", URL: ""}, + }, + Affected: []vulns.Affected{ + { + Package: vulns.Package{Ecosystem: "npm", Name: "lodash"}, + Ranges: []vulns.Range{ + {Events: []vulns.Event{{Introduced: "0"}, {Fixed: "4.17.21"}}}, + }, + }, + }, + }, + }, + } + + client := NewVulnerabilityClient(WithVulnerabilitySource(source)) + got, err := client.Check(context.Background(), "npm", "lodash", "4.17.20") + if err != nil { + t.Fatalf("Check() error: %v", err) + } + if len(got) != 1 { + t.Fatalf("len(Check()) = %d, want 1", len(got)) + } + + info := got[0] + if info.ID != "GHSA-test" { + t.Errorf("ID = %q, want GHSA-test", info.ID) + } + if info.Severity != "high" { + t.Errorf("Severity = %q, want high", info.Severity) + } + if info.CVSSScore != 7.5 { + t.Errorf("CVSSScore = %v, want 7.5", info.CVSSScore) + } + if info.CVSSVersion != "3.1" { + t.Errorf("CVSSVersion = %q, want 3.1", info.CVSSVersion) + } + if info.FixedVersion != "4.17.21" { + t.Errorf("FixedVersion = %q, want 4.17.21", info.FixedVersion) + } + if len(info.References) != 1 || info.References[0] != "https://example.com/advisory" { + t.Errorf("References = %v, want advisory URL only", info.References) + } + if len(info.Aliases) != 1 || info.Aliases[0] != "CVE-2024-0001" { + t.Errorf("Aliases = %v, want CVE alias", info.Aliases) + } + if info.Source != "fake" { + t.Errorf("Source = %q, want fake", info.Source) + } +} + +func TestVulnerabilityClientCheckBatch(t *testing.T) { + source := &fakeVulnerabilitySource{ + queryBatchResult: [][]vulns.Vulnerability{ + {{ID: "GHSA-one"}}, + nil, + }, + } + + client := NewVulnerabilityClient(WithVulnerabilitySource(source)) + got, err := client.CheckBatch(context.Background(), []VulnerabilityQuery{ + {Ecosystem: "npm", Name: "lodash", Version: "4.17.20"}, + {Ecosystem: "pypi", Name: "requests", Version: "2.31.0"}, + }) + if err != nil { + t.Fatalf("CheckBatch() error: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(CheckBatch()) = %d, want 2", len(got)) + } + if got[0].Query.Name != "lodash" || len(got[0].Vulnerabilities) != 1 { + t.Errorf("first result = %+v, want lodash with one vulnerability", got[0]) + } + if got[1].Query.Name != "requests" || got[1].Vulnerabilities != nil { + t.Errorf("second result = %+v, want requests with no vulnerabilities", got[1]) + } +} + +func TestVulnerabilityClientErrors(t *testing.T) { + t.Run("invalid query", func(t *testing.T) { + client := NewVulnerabilityClient(WithVulnerabilitySource(&fakeVulnerabilitySource{})) + if _, err := client.Check(context.Background(), "npm", "", "1.0.0"); err == nil { + t.Fatal("Check() error = nil, want error") + } + }) + + t.Run("source error", func(t *testing.T) { + wantErr := errors.New("source unavailable") + client := NewVulnerabilityClient(WithVulnerabilitySource(&fakeVulnerabilitySource{err: wantErr})) + if _, err := client.Check(context.Background(), "npm", "lodash", "4.17.20"); !errors.Is(err, wantErr) { + t.Fatalf("Check() error = %v, want %v", err, wantErr) + } + }) + + t.Run("batch result count mismatch", func(t *testing.T) { + client := NewVulnerabilityClient(WithVulnerabilitySource(&fakeVulnerabilitySource{ + queryBatchResult: [][]vulns.Vulnerability{{{ID: "only-one"}}}, + })) + _, err := client.CheckBatch(context.Background(), []VulnerabilityQuery{ + {Ecosystem: "npm", Name: "a", Version: "1.0.0"}, + {Ecosystem: "npm", Name: "b", Version: "1.0.0"}, + }) + if err == nil { + t.Fatal("CheckBatch() error = nil, want mismatch error") + } + }) +}