Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/services/supply_chain.go

866 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
import (
"bytes"
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"regexp"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/server/internal/httpx"
"github.com/gofrs/uuid/v5"
)
// OSVQueryRequest is sent to the OSV.dev API.
type OSVQueryRequest struct {
Package OSVPackage `json:"package"`
Version string `json:"version"`
}
// OSVPackage identifies a package in a specific ecosystem.
type OSVPackage struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
}
// OSVQueryResponse is the response from the OSV.dev API.
type OSVQueryResponse struct {
Vulns []OSVVuln `json:"vulns"`
}
// OSVVuln represents a single vulnerability from OSV.dev. We decode the subset
// of the OSV schema we surface in the UI: identity, qualitative severity
// (database_specific.severity, used by GHSA for npm/PyPI), CVSS vectors, the
// first fixed version (from affected ranges), and the publish date.
type OSVVuln struct {
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Published string `json:"published"`
Severity []OSVSeverity `json:"severity"`
Affected []OSVAffected `json:"affected"`
DatabaseSpecific map[string]interface{} `json:"database_specific"`
}
// OSVSeverity is one severity record (typically a CVSS vector string).
type OSVSeverity struct {
Type string `json:"type"`
Score string `json:"score"`
}
// OSVAffected carries the affected version ranges for a package.
type OSVAffected struct {
Ranges []OSVRange `json:"ranges"`
}
// OSVRange is an introduced/fixed event sequence over a version space.
type OSVRange struct {
Type string `json:"type"`
Events []OSVEvent `json:"events"`
}
// OSVEvent is a single boundary in a range (introduced or fixed).
type OSVEvent struct {
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
LastAffected string `json:"last_affected"`
Limit string `json:"limit"`
}
// SupplyChainCheckResult is returned by the vulnerability check.
type SupplyChainCheckResult struct {
Vulnerabilities []VulnerabilityInfo `json:"vulnerabilities"`
CheckedAt time.Time `json:"checked_at"`
}
// VulnerabilityInfo is a display-oriented vulnerability record. Stored as JSON
// in current_package_state.metadata.supply_chain_vulns and rendered by the UI.
// Severity is qualitative (CRITICAL/HIGH/MODERATE/LOW) when OSV provides it;
// CVSSVector is the raw v3/v4 vector for operators who want the dimensions.
type VulnerabilityInfo struct {
ID string `json:"id"`
Summary string `json:"summary"`
Aliases []string `json:"aliases"`
Severity string `json:"severity,omitempty"`
CVSSVector string `json:"cvss_vector,omitempty"`
CVSSScore float64 `json:"cvss_score,omitempty"`
FixedVersion string `json:"fixed_version,omitempty"`
Published string `json:"published,omitempty"`
AdvisoryType string `json:"advisory_type,omitempty"` // "AlmaLinux advisory", "CVE", etc.
AffectedRanges []string `json:"affected_ranges,omitempty"`
KnownExploited bool `json:"known_exploited,omitempty"`
}
// AdvisoryType returns a human-readable label for an advisory ID prefix.
func AdvisoryType(id string) string {
switch {
case strings.HasPrefix(id, "ALSA-"):
return "AlmaLinux advisory"
case strings.HasPrefix(id, "ALBA-"), strings.HasPrefix(id, "ALEA-"):
return "AlmaLinux erratum (non-security)"
case strings.HasPrefix(id, "RHSA-"):
return "Red Hat advisory"
case strings.HasPrefix(id, "RHBA-"), strings.HasPrefix(id, "RHEA-"):
return "Red Hat erratum (non-security)"
case strings.HasPrefix(id, "USN-"):
return "Ubuntu advisory"
case strings.HasPrefix(id, "GHSA-"):
return "GitHub advisory"
case strings.HasPrefix(id, "CVE-"):
return "CVE"
default:
return "security advisory"
}
}
// nonSecurityErrata matches RPM-family errata that carry no security content:
// bugfix (*BA) and enhancement (*EA) advisories from AlmaLinux, Red Hat,
// Rocky, and Oracle. OSV returns them alongside security advisories; counting
// them as threats inflates the dashboard with non-threats.
var nonSecurityErrata = regexp.MustCompile(`^(AL|RH|RL|EL)(BA|EA)-`)
// IsSecurityAdvisory reports whether an OSV record ID names an actual
// security advisory rather than a bugfix/enhancement erratum.
func IsSecurityAdvisory(id string) bool {
return !nonSecurityErrata.MatchString(id)
}
// filterSecurityVulns drops non-security errata from an OSV result set.
func filterSecurityVulns(vulns []OSVVuln) []OSVVuln {
kept := vulns[:0:0]
for _, v := range vulns {
if IsSecurityAdvisory(v.ID) {
kept = append(kept, v)
} else {
log.Printf("[INFO] [supply_chain] erratum_filtered id=%s (non-security)", v.ID)
}
}
return kept
}
// toVulnerabilityInfo maps a raw OSV record into the display struct, pulling
// qualitative severity, the first CVSS vector, the first fixed version, and the
// publish date out of the OSV schema's various nesting points.
func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
info := VulnerabilityInfo{
ID: v.ID,
Summary: v.Summary,
Aliases: v.Aliases,
Published: v.Published,
AdvisoryType: AdvisoryType(v.ID),
AffectedRanges: affectedRanges(v.Affected),
KnownExploited: knownExploited(v.DatabaseSpecific),
}
// Qualitative severity: GHSA puts it in database_specific.severity.
if v.DatabaseSpecific != nil {
if s, ok := v.DatabaseSpecific["severity"].(string); ok && s != "" {
info.Severity = strings.ToUpper(s)
}
}
// First CVSS vector (prefer v4, else v3, else whatever is present).
for _, s := range v.Severity {
if strings.HasPrefix(s.Score, "CVSS:") {
info.CVSSVector = s.Score
if strings.HasPrefix(s.Score, "CVSS:4") {
break
}
}
}
if score, ok := cvss3BaseScore(info.CVSSVector); ok {
info.CVSSScore = score
if info.Severity == "" {
info.Severity = severityFromCVSSScore(score)
}
}
// First fixed version across affected ranges.
for _, a := range v.Affected {
for _, r := range a.Ranges {
for _, e := range r.Events {
if e.Fixed != "" {
info.FixedVersion = e.Fixed
break
}
}
if info.FixedVersion != "" {
break
}
}
if info.FixedVersion != "" {
break
}
}
return info
}
func cvss3BaseScore(vector string) (float64, bool) {
if !strings.HasPrefix(vector, "CVSS:3.") {
return 0, false
}
metrics := make(map[string]string)
for _, part := range strings.Split(vector, "/") {
key, value, ok := strings.Cut(part, ":")
if ok {
metrics[key] = value
}
}
avMap := map[string]float64{"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
acMap := map[string]float64{"L": 0.77, "H": 0.44}
uiMap := map[string]float64{"N": 0.85, "R": 0.62}
impactMap := map[string]float64{"H": 0.56, "L": 0.22, "N": 0}
scope := metrics["S"]
if scope != "U" && scope != "C" {
return 0, false
}
prMap := map[string]float64{"N": 0.85, "L": 0.62, "H": 0.27}
if scope == "C" {
prMap = map[string]float64{"N": 0.85, "L": 0.68, "H": 0.5}
}
av, okAV := avMap[metrics["AV"]]
ac, okAC := acMap[metrics["AC"]]
pr, okPR := prMap[metrics["PR"]]
ui, okUI := uiMap[metrics["UI"]]
c, okC := impactMap[metrics["C"]]
i, okI := impactMap[metrics["I"]]
a, okA := impactMap[metrics["A"]]
if !okAV || !okAC || !okPR || !okUI || !okC || !okI || !okA {
return 0, false
}
exploitability := 8.22 * av * ac * pr * ui
impactSubScore := 1 - (1-c)*(1-i)*(1-a)
impact := 6.42 * impactSubScore
if scope == "C" {
impact = 7.52*(impactSubScore-0.029) - 3.25*math.Pow(impactSubScore-0.02, 15)
}
if impact <= 0 {
return 0, true
}
score := impact + exploitability
if scope == "C" {
score = 1.08 * score
}
if score > 10 {
score = 10
}
return math.Ceil((score-1e-10)*10) / 10, true
}
func severityFromCVSSScore(score float64) string {
switch {
case score >= 9:
return "CRITICAL"
case score >= 7:
return "HIGH"
case score >= 4:
return "MEDIUM"
case score > 0:
return "LOW"
default:
return ""
}
}
func affectedRanges(affected []OSVAffected) []string {
var ranges []string
for _, a := range affected {
for _, r := range a.Ranges {
var introduced string
emitted := false
for _, e := range r.Events {
if e.Introduced != "" {
introduced = e.Introduced
}
switch {
case e.Fixed != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<", e.Fixed))
introduced = ""
emitted = true
case e.LastAffected != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<=", e.LastAffected))
emitted = true
case e.Limit != "":
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "<", e.Limit))
emitted = true
}
}
if !emitted && introduced != "" {
ranges = append(ranges, formatAffectedRange(r.Type, introduced, "", ""))
}
}
}
return ranges
}
func formatAffectedRange(rangeType, introduced, upperOp, upperVersion string) string {
lower := "all prior versions"
if introduced != "" && introduced != "0" {
lower = ">= " + introduced
}
body := lower + " and later"
if upperOp != "" && upperVersion != "" {
body = lower + ", " + upperOp + " " + upperVersion
}
if rangeType != "" {
return rangeType + ": " + body
}
return body
}
func knownExploited(databaseSpecific map[string]interface{}) bool {
keys := []string{
"known_exploited",
"knownExploited",
"known_exploited_vulnerability",
"knownExploitedVulnerability",
"cisa_kev",
"cisaKev",
"cisa_known_exploited",
"cisaKnownExploited",
"cisaExploitAdd",
"cisaActionDue",
"cisaRequiredAction",
"cisaVulnerabilityName",
"kev",
}
for _, key := range keys {
if truthy(databaseSpecific[key]) {
return true
}
}
return false
}
func truthy(v interface{}) bool {
switch value := v.(type) {
case bool:
return value
case string:
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized != "" &&
normalized != "false" &&
normalized != "no" &&
normalized != "none" &&
normalized != "unknown" &&
normalized != "0"
case float64:
return value > 0
case int:
return value > 0
case []interface{}:
return len(value) > 0
case map[string]interface{}:
return len(value) > 0
default:
return false
}
}
var osvHTTPClient = httpx.NewClient(30 * time.Second)
// osvBreaker wraps OSV.dev calls (SCALE-001 S8). Both the batch and single-query
// paths hit api.osv.dev, so they share one breaker: when OSV is down or slow,
// the breaker opens and subsequent checks fail fast instead of each timing out.
// Callers fail OPEN on an open breaker (record the check as unrun) — sovereignty:
// an unreachable advisory feed never blocks a patch, same as today's transport
// errors.
var osvBreaker = circuitbreaker.New("osv", circuitbreaker.Config{
FailureThreshold: 5,
FailureWindow: 60 * time.Second,
OpenDuration: 30 * time.Second,
HalfOpenAttempts: 2,
})
// OSVBreakerStats exposes the OSV breaker state for /health/tasks (OBS-001).
func OSVBreakerStats() circuitbreaker.Stats { return osvBreaker.GetStats() }
// OSVRecheckInterval is how long a stored supply-chain result is treated as
// fresh. The scan path skips packages checked more recently than this so a
// frequent scan cadence does not re-hit OSV.dev for every package every cycle;
// the periodic recheck still picks up newly published advisories.
const OSVRecheckInterval = 6 * time.Hour
// osvBatchSize is the max queries per /v1/querybatch POST. OSV.dev accepts up
// to 1000; 100 keeps each request under 2s and the response payload manageable.
const osvBatchSize = 100
// osvBatchesInFlight caps the number of concurrent batch requests to OSV.dev
// across the entire process. At 10,000 agents × ~300 packages each, we could
// have millions of checks queued — this semaphore ensures we don't hammer the
// API regardless of how many callers invoke RunOSVChecks simultaneously.
const osvBatchesInFlight = 4
// osvBatchSem is the process-wide semaphore for concurrent OSV.dev batch
// requests. Shared across all RunOSVChecks calls so that N agents reporting
// simultaneously total at most osvBatchesInFlight requests in flight.
var osvBatchSem = make(chan struct{}, osvBatchesInFlight)
// OSVCheckRequest is one package to check against OSV.dev.
// Namespace routes results to different metadata keys:
// - "" or "remediation" → supply_chain_checked_at / supply_chain_checked_version / supply_chain_vulns
// - "installed" → installed_checked_at / installed_checked_version / installed_vulns
type OSVCheckRequest struct {
AgentID uuid.UUID
PkgType string
PkgName string
Version string
Namespace string // "" = remediation (default)
}
// OSVStoreFunc persists a supply-chain result (or a recorded failure) for one
// package. Implemented by the caller against current_package_state so this
// package keeps no database dependency.
type OSVStoreFunc func(agentID uuid.UUID, pkgType, pkgName string, meta map[string]interface{}) error
// osvBatchQuery is one entry in the /v1/querybatch request.
type osvBatchQuery struct {
Package OSVPackage `json:"package"`
Version string `json:"version"`
}
// osvBatchRequest is the POST body for /v1/querybatch.
type osvBatchRequest struct {
Queries []osvBatchQuery `json:"queries"`
}
// osvBatchResult is one result from /v1/querybatch.
type osvBatchResult struct {
Vulns []OSVVuln `json:"vulns"`
}
// osvBatchResponse is the response from /v1/querybatch.
type osvBatchResponse struct {
Results []osvBatchResult `json:"results"`
}
// RunOSVChecks queries OSV.dev for each request using the batch endpoint with
// bounded, process-wide concurrency, and persists every outcome through store.
// A clean result records checked_at and checked_version, and clears stale vulns;
// a hit also records vulns. A query failure records check_error WITHOUT a
// checked_at timestamp, so the package stays a candidate for the next run rather
// than being silently dropped (ETHOS: errors are history, assume failure).
// Blocks until done.
func RunOSVChecks(reqs []OSVCheckRequest, store OSVStoreFunc) {
if len(reqs) == 0 {
return
}
var wg sync.WaitGroup
for i := 0; i < len(reqs); i += osvBatchSize {
end := i + osvBatchSize
if end > len(reqs) {
end = len(reqs)
}
batch := reqs[i:end]
osvBatchSem <- struct{}{}
wg.Add(1)
go func(b []OSVCheckRequest) {
defer wg.Done()
defer func() { <-osvBatchSem }()
osvBatchRun(b, store)
}(batch)
}
wg.Wait()
}
// osvBatchRun sends one batch to /v1/querybatch and persists all results.
func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
queries := make([]osvBatchQuery, len(reqs))
for i, r := range reqs {
queries[i] = osvBatchQuery{
Package: OSVPackage{
Name: r.PkgName,
Ecosystem: EcosystemFromPackageType(r.PkgType),
},
Version: r.Version,
}
}
body, err := json.Marshal(osvBatchRequest{Queries: queries})
if err != nil {
log.Printf("[WARNING] [supply_chain] batch_marshal_failed count=%d error=%v", len(reqs), err)
recordBatchFailure(reqs, store)
return
}
// Breaker-wrapped (SCALE-001 S8): a down/slow OSV trips the breaker so the
// rest of this batch — and the single-query path — fail fast rather than each
// blocking on a 30s timeout. An open breaker takes the same fail-open path as
// a transport error below.
var batchResp osvBatchResponse
callErr := osvBreaker.Call(func() error {
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/querybatch", "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("osv batch status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
})
if callErr != nil {
log.Printf("[WARNING] [supply_chain] batch_query_failed count=%d error=%v", len(reqs), callErr)
recordBatchFailure(reqs, store)
return
}
if len(batchResp.Results) != len(reqs) {
log.Printf("[WARNING] [supply_chain] batch_count_mismatch sent=%d got=%d", len(reqs), len(batchResp.Results))
recordBatchFailure(reqs, store)
return
}
now := time.Now().UTC()
for i, r := range reqs {
result := batchResp.Results[i]
checkedKey, versionKey, vulnsKey, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
checkedKey: now.Format(time.RFC3339),
versionKey: r.Version,
vulnsKey: nil,
errorKey: nil,
}
securityVulns := filterSecurityVulns(result.Vulns)
if len(securityVulns) > 0 {
// Enrich ALSA/RHSA/USN advisories with full details from OSV.
enriched := enrichAdvisoryVulns(securityVulns)
vulns := make([]VulnerabilityInfo, len(enriched))
for i, v := range enriched {
vulns[i] = toVulnerabilityInfo(v)
}
vulnJSON, err := json.Marshal(vulns)
if err != nil {
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
continue
}
meta[vulnsKey] = string(vulnJSON)
log.Printf("[SECURITY] [supply_chain] vulns_found namespace=%s pkg=%s type=%s ecosystem=%s count=%d",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), len(securityVulns))
} else {
log.Printf("[INFO] [supply_chain] clean namespace=%s pkg=%s type=%s ecosystem=%s version=%s",
r.Namespace, r.PkgName, r.PkgType, EcosystemFromPackageType(r.PkgType), r.Version)
}
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] metadata_store_failed pkg=%s error=%v", r.PkgName, err)
}
}
}
// enrichAdvisoryVulns does a second OSV pass for ALSA/RHSA/USN advisories to
// resolve their aliases and summaries. OSV's batch endpoint returns advisory IDs
// but often without full details; the single-vuln endpoint (/v1/vulns/{id})
// returns the complete record including constituent CVEs.
func enrichAdvisoryVulns(vulns []OSVVuln) []OSVVuln {
enriched := make([]OSVVuln, 0, len(vulns))
for _, v := range vulns {
if needsEnrichment(v) {
if resolved, err := fetchVulnDetail(v.ID); err == nil {
enriched = append(enriched, resolved)
continue
}
// On failure, keep original — partial data is better than none.
log.Printf("[WARNING] [supply_chain] enrich_failed id=%s", v.ID)
}
enriched = append(enriched, v)
}
return enriched
}
// needsEnrichment returns true if a vuln record looks sparse — missing
// summary and aliases — and its ID is a known advisory prefix.
func needsEnrichment(v OSVVuln) bool {
if v.Summary != "" && len(v.Aliases) > 0 {
return false
}
return strings.HasPrefix(v.ID, "ALSA-") ||
strings.HasPrefix(v.ID, "RHSA-") ||
strings.HasPrefix(v.ID, "USN-")
}
// fetchVulnDetail queries OSV.dev for a single vulnerability ID.
func fetchVulnDetail(id string) (OSVVuln, error) {
url := fmt.Sprintf("https://api.osv.dev/v1/vulns/%s", id)
resp, err := osvHTTPClient.Get(url)
if err != nil {
return OSVVuln{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return OSVVuln{}, fmt.Errorf("osv status %d", resp.StatusCode)
}
var v OSVVuln
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return OSVVuln{}, err
}
return v, nil
}
// osvMetaKeys returns the metadata key names for a given check namespace.
func osvMetaKeys(namespace string) (checkedAt, checkedVersion, vulns, checkError string) {
if namespace == "installed" {
return "installed_checked_at", "installed_checked_version", "installed_vulns", "installed_check_error"
}
return "supply_chain_checked_at", "supply_chain_checked_version", "supply_chain_vulns", "supply_chain_check_error"
}
// recordBatchFailure persists a failure record for every request in a batch
// that could not be checked (HTTP error, decode failure, count mismatch).
// No checked_at is set so these packages are retried next cycle.
func recordBatchFailure(reqs []OSVCheckRequest, store OSVStoreFunc) {
for _, r := range reqs {
_, _, _, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
errorKey: "osv_batch_failed",
"supply_chain_error_at": time.Now().UTC().Format(time.RFC3339),
}
if err := store(r.AgentID, r.PkgType, r.PkgName, meta); err != nil {
log.Printf("[WARNING] [supply_chain] failure_record_failed pkg=%s error=%v", r.PkgName, err)
}
}
}
// ClosurePkg is one resolved artifact (name + version) in a dependency closure
// to be checked against OSV.dev.
type ClosurePkg struct {
Name string
Version string
}
// ClosureVuln records a closure artifact that has known vulnerabilities.
type ClosureVuln struct {
Name string `json:"name"`
Version string `json:"version"`
Vulns []OSVVuln `json:"vulns"`
}
// CheckClosureOSV queries OSV.dev for every artifact in a resolved dependency
// closure and returns the subset with known vulnerabilities. This is the
// dependency-level supply-chain gate: the closure is the exact set of artifacts
// the capability token authorizes the network-less executor to install, so each
// transitive artifact is queried — not just the top-level package that was
// checked at discovery.
//
// Unlike CheckOSVVulnerabilities (fail-open, for advisory display), this is
// FAIL-CLOSED: if any batch cannot be queried (HTTP error, bad status, decode
// failure, count mismatch), it returns ok=false. The caller must treat a
// not-ok result as "closure not cleared" and never as clean — auto-confirm must
// not mint a token over a closure OSV could not vet. All entries share the
// update's ecosystem.
func CheckClosureOSV(pkgType string, entries []ClosurePkg) (found []ClosureVuln, ok bool) {
if len(entries) == 0 {
return nil, true // empty closure is trivially clean
}
ecosystem := EcosystemFromPackageType(pkgType)
for i := 0; i < len(entries); i += osvBatchSize {
end := i + osvBatchSize
if end > len(entries) {
end = len(entries)
}
batch := entries[i:end]
hits, batchOK := osvClosureBatch(ecosystem, batch)
if !batchOK {
return found, false // fail closed: a batch we couldn't check is not "clean"
}
found = append(found, hits...)
}
return found, true
}
// osvClosureBatch sends one closure batch to /v1/querybatch under the
// process-wide semaphore and returns the entries with vulns. ok=false on any
// query/parse failure.
func osvClosureBatch(ecosystem string, batch []ClosurePkg) (found []ClosureVuln, ok bool) {
queries := make([]osvBatchQuery, len(batch))
for i, e := range batch {
queries[i] = osvBatchQuery{Package: OSVPackage{Name: e.Name, Ecosystem: ecosystem}, Version: e.Version}
}
body, err := json.Marshal(osvBatchRequest{Queries: queries})
if err != nil {
log.Printf("[WARNING] [supply_chain] closure_batch_marshal_failed count=%d error=%v", len(batch), err)
return nil, false
}
osvBatchSem <- struct{}{}
defer func() { <-osvBatchSem }()
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/querybatch", "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("[WARNING] [supply_chain] closure_batch_query_failed count=%d error=%v", len(batch), err)
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] [supply_chain] closure_batch_query_status=%d count=%d", resp.StatusCode, len(batch))
return nil, false
}
var batchResp osvBatchResponse
if err := json.NewDecoder(resp.Body).Decode(&batchResp); err != nil {
log.Printf("[WARNING] [supply_chain] closure_batch_decode_failed count=%d error=%v", len(batch), err)
return nil, false
}
if len(batchResp.Results) != len(batch) {
log.Printf("[WARNING] [supply_chain] closure_batch_count_mismatch sent=%d got=%d", len(batch), len(batchResp.Results))
return nil, false
}
for i, e := range batch {
securityVulns := filterSecurityVulns(batchResp.Results[i].Vulns)
if len(securityVulns) > 0 {
found = append(found, ClosureVuln{Name: e.Name, Version: e.Version, Vulns: securityVulns})
log.Printf("[SECURITY] [supply_chain] closure_vuln pkg=%s version=%s ecosystem=%s count=%d",
e.Name, e.Version, ecosystem, len(securityVulns))
}
}
return found, true
}
// CheckOSVVulnerabilities queries the OSV.dev API for known vulnerabilities
// matching the given package name, ecosystem, and version. Returns nil slice
// with no error on clean results or API failure (fail-open).
func CheckOSVVulnerabilities(pkgName, ecosystem, version string) *SupplyChainCheckResult {
reqBody := OSVQueryRequest{
Package: OSVPackage{
Name: pkgName,
Ecosystem: ecosystem,
},
Version: version,
}
body, err := json.Marshal(reqBody)
if err != nil {
log.Printf("[WARNING] [supply_chain] marshal_failed pkg=%s error=%v", pkgName, err)
return nil
}
// Breaker-wrapped (SCALE-001 S8); shares osvBreaker with the batch path. An
// open breaker returns nil (fail-open) — same as a transport error: the check
// is recorded as unrun, never blocking a patch (sovereignty).
var result OSVQueryResponse
callErr := osvBreaker.Call(func() error {
resp, err := osvHTTPClient.Post("https://api.osv.dev/v1/query", "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("osv status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode: %w", err)
}
return nil
})
if callErr != nil {
log.Printf("[WARNING] [supply_chain] query_failed pkg=%s error=%v", pkgName, callErr)
return nil
}
securityVulns := filterSecurityVulns(result.Vulns)
if len(securityVulns) == 0 {
return &SupplyChainCheckResult{
CheckedAt: time.Now().UTC(),
}
}
vulns := make([]VulnerabilityInfo, len(securityVulns))
for i, v := range securityVulns {
vulns[i] = toVulnerabilityInfo(v)
}
return &SupplyChainCheckResult{
Vulnerabilities: vulns,
CheckedAt: time.Now().UTC(),
}
}
// NeedsSupplyChainCheck returns true if the given package ecosystem can be
// checked against OSV.dev. Broader than before — runs for all ecosystems that
// have an OSV.dev mapping, even when coverage is sparse. A nil result is honest
// visibility (the check ran, nothing found).
//
// TODO(GATE-006): pacman is wired as a scanner but OSV.dev does not yet cover
// Arch Linux (as of 2026-06). When OSV adds Arch support, add "pacman" here
// and flip EcosystemFromPackageType to return "Arch". Track via OSV.dev
// ecosystem registry — search for "arch" or "archlinux". Until then, pacman
// updates skip the supply-chain check and rely on the capability gate + hash
// verification for install safety.
func NeedsSupplyChainCheck(pkgType string) bool {
switch pkgType {
case "npm", "pypi", "apt", "dnf":
return true
}
return false
}
// CanServerFetchArtifact returns true if the server can download package
// artifacts from the public registry for the given ecosystem. Agent-sourced
// ecosystems (dnf, apt) return false — their artifacts come from the agent's
// own repos, which the server may not be able to reach (custom mirrors,
// air-gapped networks). The mirror tier may add server-side fetching for
// these ecosystems later.
func CanServerFetchArtifact(pkgType string) bool {
switch pkgType {
case "npm", "pypi":
return true
}
return false
}
// NeedsCapabilityGate returns true if the ecosystem routes mutation through
// the capability-token path (consumer.go → redflag-helper). Server-fetched
// ecosystems (npm/pypi) and agent-sourced ecosystems (dnf/apt) both use it
// when the minter is enabled. pacman routes through the gate for privilege
// isolation and artifact hashing even though OSV does not cover Arch.
func NeedsCapabilityGate(pkgType string) bool {
switch pkgType {
case "dnf", "apt", "npm", "pypi", "pacman":
return true
}
return false
}
// EcosystemFromPackageType maps RedFlag package types to OSV.dev ecosystems.
// Best-effort: dnf maps to AlmaLinux (closest supported RHEL-family ecosystem),
// apt maps to Debian. Unmapped types return the raw package type — OSV.dev will
// return empty results for unrecognized ecosystems rather than error. pacman
// returns "Arch" for future OSV coverage; as of 2026-06 OSV.dev does not support
// Arch Linux, so NeedsSupplyChainCheck returns false for pacman.
func EcosystemFromPackageType(pkgType string) string {
switch pkgType {
case "npm":
return "npm"
case "pypi":
return "PyPI"
case "apt":
return "Debian"
case "dnf":
return "AlmaLinux"
case "pacman":
return "Arch"
}
return pkgType
}