Cybersecurity-Projects/PROJECTS/intermediate/security-news-scraper/internal/cve/cvelist.go

250 lines
5.8 KiB
Go

// ©AngelaMos | 2026
// cvelist.go
package cve
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"time"
"golang.org/x/time/rate"
)
const (
CVEListEndpoint = "https://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves"
cvelistRate = 15
cvelistMaxRetries = 2
cvelistBackoff = time.Second
cvelistBucketTail = 3
cveIDParts = 3
langEnPrefix = "en"
cvssCriticalMin = 9.0
cvssHighMin = 7.0
cvssMediumMin = 4.0
cvssVersion2Prefix = "2"
sevCritical = "CRITICAL"
sevHigh = "HIGH"
sevMedium = "MEDIUM"
sevLow = "LOW"
sevNone = "NONE"
)
type CoreResult struct {
Found bool
Description string
CVSSScore *float64
CVSSVersion string
CVSSSeverity string
CVSSVector string
CWE string
Published string
Modified string
}
type CVESource interface {
Fetch(ctx context.Context, cveID string) (CoreResult, error)
}
type CVEListClient struct {
http *http.Client
baseURL string
limiter *rate.Limiter
backoffBase time.Duration
}
func NewCVEListClient(client *http.Client, baseURL string) *CVEListClient {
return &CVEListClient{
http: client,
baseURL: strings.TrimRight(baseURL, "/"),
limiter: rate.NewLimiter(rate.Limit(cvelistRate), 1),
backoffBase: cvelistBackoff,
}
}
func (c *CVEListClient) Fetch(ctx context.Context, cveID string) (CoreResult, error) {
endpoint, err := c.recordURL(cveID)
if err != nil {
return CoreResult{}, err
}
var rec cvelistRecord
for attempt := 0; attempt <= cvelistMaxRetries; attempt++ {
if attempt > 0 {
if err := sleep(ctx, c.backoffBase*time.Duration(1<<(attempt-1))); err != nil {
return CoreResult{}, err
}
}
if err := c.limiter.Wait(ctx); err != nil {
return CoreResult{}, err
}
err := getJSON(ctx, c.http, endpoint, nil, &rec)
if err == nil {
return rec.toResult(), nil
}
var se *statusError
if errors.As(err, &se) && se.code == http.StatusNotFound {
return CoreResult{Found: false}, nil
}
if !retriable(err) || attempt == cvelistMaxRetries {
return CoreResult{}, err
}
}
return CoreResult{Found: false}, nil
}
func (c *CVEListClient) recordURL(cveID string) (string, error) {
parts := strings.Split(cveID, "-")
if len(parts) != cveIDParts || parts[0] != "CVE" {
return "", fmt.Errorf("cve: malformed id %q", cveID)
}
num := parts[2]
if len(num) <= cvelistBucketTail {
return "", fmt.Errorf("cve: short id %q", cveID)
}
bucket := num[:len(num)-cvelistBucketTail] + "xxx"
return fmt.Sprintf("%s/%s/%s/%s.json", c.baseURL, parts[1], bucket, cveID), nil
}
type cvelistRecord struct {
CVEMetadata struct {
DatePublished string `json:"datePublished"`
DateUpdated string `json:"dateUpdated"`
} `json:"cveMetadata"`
Containers struct {
CNA cvelistContainer `json:"cna"`
ADP []cvelistContainer `json:"adp"`
} `json:"containers"`
}
type cvelistContainer struct {
Descriptions []cvelistLangValue `json:"descriptions"`
ProblemTypes []struct {
Descriptions []struct {
Lang string `json:"lang"`
CweID string `json:"cweId"`
} `json:"descriptions"`
} `json:"problemTypes"`
Metrics []cvelistMetric `json:"metrics"`
}
type cvelistLangValue struct {
Lang string `json:"lang"`
Value string `json:"value"`
}
type cvelistMetric struct {
V40 *cvelistCVSS `json:"cvssV4_0"`
V31 *cvelistCVSS `json:"cvssV3_1"`
V30 *cvelistCVSS `json:"cvssV3_0"`
V2 *cvelistCVSS `json:"cvssV2_0"`
}
type cvelistCVSS struct {
Version string `json:"version"`
BaseScore float64 `json:"baseScore"`
BaseSeverity string `json:"baseSeverity"`
VectorString string `json:"vectorString"`
}
func (rec cvelistRecord) containers() []cvelistContainer {
out := make([]cvelistContainer, 0, len(rec.Containers.ADP)+1)
out = append(out, rec.Containers.CNA)
out = append(out, rec.Containers.ADP...)
return out
}
func (rec cvelistRecord) toResult() CoreResult {
res := CoreResult{
Found: true,
Description: rec.description(),
CWE: rec.cwe(),
Published: rec.CVEMetadata.DatePublished,
Modified: rec.CVEMetadata.DateUpdated,
}
if cv := rec.cvss(); cv != nil {
score := cv.BaseScore
res.CVSSScore = &score
res.CVSSVersion = cv.Version
res.CVSSVector = cv.VectorString
res.CVSSSeverity = cv.BaseSeverity
if res.CVSSSeverity == "" {
res.CVSSSeverity = severityFromScore(score, cv.Version)
}
}
return res
}
func (rec cvelistRecord) cvss() *cvelistCVSS {
tiers := []func(cvelistMetric) *cvelistCVSS{
func(m cvelistMetric) *cvelistCVSS { return m.V40 },
func(m cvelistMetric) *cvelistCVSS { return m.V31 },
func(m cvelistMetric) *cvelistCVSS { return m.V30 },
func(m cvelistMetric) *cvelistCVSS { return m.V2 },
}
containers := rec.containers()
for _, pick := range tiers {
for _, c := range containers {
for _, m := range c.Metrics {
if cv := pick(m); cv != nil {
return cv
}
}
}
}
return nil
}
func (rec cvelistRecord) cwe() string {
for _, c := range rec.containers() {
for _, pt := range c.ProblemTypes {
for _, d := range pt.Descriptions {
if d.CweID != "" {
return d.CweID
}
}
}
}
return ""
}
func (rec cvelistRecord) description() string {
for _, c := range rec.containers() {
for _, d := range c.Descriptions {
if strings.HasPrefix(d.Lang, langEnPrefix) && strings.TrimSpace(d.Value) != "" {
return d.Value
}
}
}
for _, c := range rec.containers() {
for _, d := range c.Descriptions {
if strings.TrimSpace(d.Value) != "" {
return d.Value
}
}
}
return ""
}
func severityFromScore(score float64, version string) string {
switch {
case score <= 0:
return sevNone
case score >= cvssCriticalMin && !strings.HasPrefix(version, cvssVersion2Prefix):
return sevCritical
case score >= cvssHighMin:
return sevHigh
case score >= cvssMediumMin:
return sevMedium
default:
return sevLow
}
}