added artifacts
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
package vulnscan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultOSVAPI = "https://api.osv.dev/v1"
|
||||
|
||||
// Client queries the OSV (Open Source Vulnerabilities) API.
|
||||
// https://osv.dev/docs/
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a client that queries the public OSV API.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
baseURL: defaultOSVAPI,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// QueryRequest is sent to POST /v1/query.
|
||||
type QueryRequest struct {
|
||||
Package PackageID `json:"package"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// PackageID identifies a package in a specific ecosystem.
|
||||
type PackageID struct {
|
||||
PURL string `json:"purl,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Ecosystem string `json:"ecosystem,omitempty"`
|
||||
}
|
||||
|
||||
// QueryResponse is the response from POST /v1/query.
|
||||
type QueryResponse struct {
|
||||
Vulns []OSVVuln `json:"vulns"`
|
||||
}
|
||||
|
||||
// OSVVuln is a vulnerability returned by the OSV API.
|
||||
type OSVVuln struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Details string `json:"details"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Fixed string `json:"fixed,omitempty"`
|
||||
Severity []Severity `json:"severity,omitempty"`
|
||||
Affected []Affected `json:"affected,omitempty"`
|
||||
Published string `json:"published,omitempty"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
}
|
||||
|
||||
// Severity holds a CVSS score from the OSV response.
|
||||
type Severity struct {
|
||||
Type string `json:"type"`
|
||||
Score string `json:"score"`
|
||||
}
|
||||
|
||||
// Affected describes a package version range.
|
||||
type Affected struct {
|
||||
Package PackageID `json:"package"`
|
||||
Ranges []AffectedRange `json:"ranges"`
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
|
||||
type AffectedRange struct {
|
||||
Type string `json:"type"`
|
||||
Events []RangeEvent `json:"events"`
|
||||
}
|
||||
|
||||
type RangeEvent struct {
|
||||
Introduced string `json:"introduced"`
|
||||
Fixed string `json:"fixed"`
|
||||
Limit string `json:"limit"`
|
||||
}
|
||||
|
||||
// QueryByPURL queries OSV for vulnerabilities affecting a given PURL + version.
|
||||
func (c *Client) QueryByPURL(purl, version string) ([]OSVVuln, error) {
|
||||
body := QueryRequest{
|
||||
Package: PackageID{PURL: purl},
|
||||
Version: version,
|
||||
}
|
||||
return c.doQuery(body)
|
||||
}
|
||||
|
||||
// QueryByEcosystem queries OSV for vulnerabilities affecting a package in a
|
||||
// specific ecosystem (e.g. "npm", "Go", "PyPI", "cargo", "Maven", "RubyGems").
|
||||
func (c *Client) QueryByEcosystem(ecosystem, name, version string) ([]OSVVuln, error) {
|
||||
body := QueryRequest{
|
||||
Package: PackageID{
|
||||
Name: name,
|
||||
Ecosystem: ecosystem,
|
||||
},
|
||||
Version: version,
|
||||
}
|
||||
return c.doQuery(body)
|
||||
}
|
||||
|
||||
func (c *Client) doQuery(body interface{}) ([]OSVVuln, error) {
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vulnscan: marshal body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/query", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vulnscan: create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vulnscan: query: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vulnscan: read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("vulnscan: OSV returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var qr QueryResponse
|
||||
if err := json.Unmarshal(respBody, &qr); err != nil {
|
||||
return nil, fmt.Errorf("vulnscan: parse response: %w", err)
|
||||
}
|
||||
return qr.Vulns, nil
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package vulnscan
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseCVSS(t *testing.T) {
|
||||
v := OSVVuln{
|
||||
ID: "CVE-2024-0001",
|
||||
Severity: []Severity{
|
||||
{Type: "CVSS_V3", Score: "7.5"},
|
||||
},
|
||||
}
|
||||
score := parseCVSS(v)
|
||||
if score != 7.5 {
|
||||
t.Errorf("expected 7.5, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCVSS_NoScore(t *testing.T) {
|
||||
v := OSVVuln{
|
||||
ID: "GHSA-xxxx",
|
||||
}
|
||||
score := parseCVSS(v)
|
||||
if score != 0 {
|
||||
t.Errorf("expected 0 for no severity, got %f", score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFixedVersion(t *testing.T) {
|
||||
v := OSVVuln{
|
||||
Affected: []Affected{
|
||||
{
|
||||
Ranges: []AffectedRange{
|
||||
{
|
||||
Events: []RangeEvent{
|
||||
{Introduced: "0"},
|
||||
{Fixed: "1.2.3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
fixed := extractFixedVersion(v)
|
||||
if fixed != "1.2.3" {
|
||||
t.Errorf("expected 1.2.3, got %s", fixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFixedVersion_None(t *testing.T) {
|
||||
v := OSVVuln{}
|
||||
fixed := extractFixedVersion(v)
|
||||
if fixed != "" {
|
||||
t.Errorf("expected empty, got %s", fixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateStr(t *testing.T) {
|
||||
if truncateStr("short", 10) != "short" {
|
||||
t.Error("should not truncate short strings")
|
||||
}
|
||||
if truncateStr("this is a long string", 10) != "this is a ..." {
|
||||
t.Errorf("got %q", truncateStr("this is a long string", 10))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
c := NewClient()
|
||||
if c.baseURL != defaultOSVAPI {
|
||||
t.Errorf("baseURL = %s, want %s", c.baseURL, defaultOSVAPI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRequest_Marshal(t *testing.T) {
|
||||
body := QueryRequest{
|
||||
Package: PackageID{PURL: "pkg:golang/github.com/foo/bar@v1.0.0"},
|
||||
Version: "v1.0.0",
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
// Ensure it produces valid JSON.
|
||||
if len(data) == 0 {
|
||||
t.Error("empty JSON")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package vulnscan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/forgeo/forgebucket/internal/events"
|
||||
"github.com/forgeo/forgebucket/internal/models"
|
||||
)
|
||||
|
||||
// Scanner watches for SBOM generation events and queries OSV for vulns.
|
||||
type Scanner struct {
|
||||
db *xorm.Engine
|
||||
bus events.EventBus
|
||||
client *Client
|
||||
}
|
||||
|
||||
func NewScanner(db *xorm.Engine, bus events.EventBus) *Scanner {
|
||||
return &Scanner{
|
||||
db: db,
|
||||
bus: bus,
|
||||
client: NewClient(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start subscribes to SBOM-related events and scans for vulnerabilities.
|
||||
func (s *Scanner) Start(ctx context.Context) {
|
||||
// Listen for SBOM Report created events (sync trigger).
|
||||
// In practice this is called on-demand via the API, so Start is minimal.
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
// ScanByPURL queries OSV for a single package and stores findings.
|
||||
func (s *Scanner) ScanByPURL(repoID int64, purl, version string) ([]models.VulnerabilityFinding, error) {
|
||||
vulns, err := s.client.QueryByPURL(purl, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.persistFindings(repoID, purl, version, vulns), nil
|
||||
}
|
||||
|
||||
// ScanSBOM reads the latest SBOM report for a repo, queries OSV for every
|
||||
// component, and stores the findings. Returns the new findings.
|
||||
func (s *Scanner) ScanSBOM(repoID int64) ([]models.VulnerabilityFinding, error) {
|
||||
var report models.SBOMReport
|
||||
found, err := s.db.Where("repo_id = ?", repoID).
|
||||
OrderBy("generated_at DESC").Get(&report)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("no SBOM found for repo %d", repoID)
|
||||
}
|
||||
|
||||
var doc struct {
|
||||
Components []struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
PURL string `json:"purl"`
|
||||
} `json:"components"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(report.BOMDocument), &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse SBOM: %w", err)
|
||||
}
|
||||
|
||||
var allFindings []models.VulnerabilityFinding
|
||||
for _, comp := range doc.Components {
|
||||
if comp.PURL == "" || comp.Version == "" {
|
||||
continue
|
||||
}
|
||||
vulns, err := s.client.QueryByPURL(comp.PURL, comp.Version)
|
||||
if err != nil {
|
||||
log.Printf("vulnscan: query %s@%s: %v", comp.PURL, comp.Version, err)
|
||||
continue
|
||||
}
|
||||
findings := s.persistFindings(repoID, comp.PURL, comp.Version, vulns)
|
||||
allFindings = append(allFindings, findings...)
|
||||
}
|
||||
return allFindings, nil
|
||||
}
|
||||
|
||||
// ListFindings returns unfixed vulnerability findings for a repo.
|
||||
func (s *Scanner) ListFindings(repoID int64) ([]models.VulnerabilityFinding, error) {
|
||||
var findings []models.VulnerabilityFinding
|
||||
if err := s.db.Where("repo_id = ? AND dismissed = ?", repoID, false).
|
||||
OrderBy("cvss_score DESC, detected_at DESC").Find(&findings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if findings == nil {
|
||||
findings = []models.VulnerabilityFinding{}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
// DismissFindings acknowledges a vulnerability finding.
|
||||
func (s *Scanner) DismissFindings(findingID int64, dismissedBy string) error {
|
||||
now := time.Now().UTC()
|
||||
affected, err := s.db.ID(findingID).Cols("dismissed", "dismissed_by", "dismissed_at").
|
||||
Update(&models.VulnerabilityFinding{
|
||||
Dismissed: true,
|
||||
DismissedBy: dismissedBy,
|
||||
DismissedAt: &now,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("finding %d not found", findingID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scanner) persistFindings(repoID int64, purl, version string, vulns []OSVVuln) []models.VulnerabilityFinding {var findings []models.VulnerabilityFinding
|
||||
for _, v := range vulns {
|
||||
// Check for duplicate before inserting.
|
||||
existing := &models.VulnerabilityFinding{}
|
||||
if has, _ := s.db.Where("vuln_id = ? AND purl = ? AND repo_id = ?", v.ID, purl, repoID).Get(existing); has {
|
||||
continue
|
||||
}
|
||||
|
||||
cvssScore := parseCVSS(v)
|
||||
|
||||
finding := &models.VulnerabilityFinding{
|
||||
RepoID: repoID,
|
||||
VulnID: v.ID,
|
||||
PURL: purl,
|
||||
Version: version,
|
||||
Summary: truncateStr(v.Summary, 300),
|
||||
Details: v.Details,
|
||||
CVSSScore: cvssScore,
|
||||
FixedVersion: extractFixedVersion(v),
|
||||
DetectedAt: time.Now().UTC(),
|
||||
}
|
||||
if _, err := s.db.Insert(finding); err != nil {
|
||||
log.Printf("vulnscan: insert finding %s for %s: %v", v.ID, purl, err)
|
||||
continue
|
||||
}
|
||||
findings = append(findings, *finding)
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
// parseCVSS extracts the CVSS score from OSV severity info.
|
||||
func parseCVSS(v OSVVuln) float64 {
|
||||
for _, sev := range v.Severity {
|
||||
if sev.Type == "CVSS_V3" || sev.Type == "CVSS_V2" {
|
||||
var score float64
|
||||
fmt.Sscanf(sev.Score, "%f", &score)
|
||||
return score
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractFixedVersion tries to extract the fixed version from affected ranges.
|
||||
func extractFixedVersion(v OSVVuln) string {
|
||||
for _, a := range v.Affected {
|
||||
for _, r := range a.Ranges {
|
||||
for _, e := range r.Events {
|
||||
if e.Fixed != "" {
|
||||
return e.Fixed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user