added artifacts

This commit is contained in:
2026-05-12 22:34:26 +02:00
parent 822f723ff1
commit 91462500f0
30 changed files with 2769 additions and 4 deletions
+140
View File
@@ -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
}
+89
View File
@@ -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")
}
}
+179
View File
@@ -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] + "..."
}