Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -73,4 +73,5 @@ type VersionInfo struct {
PublishedAt time.Time
Integrity string
License string
Yanked bool
}
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
51 changes: 51 additions & 0 deletions helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
50 changes: 50 additions & 0 deletions helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 2 additions & 0 deletions registries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
}

Expand Down
190 changes: 190 additions & 0 deletions vulnerabilities.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading