Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/services/supply_chain.go
Fimeg c0a717ab26 fix: README, .env.example, ErrorBoundary, client-logger, HEALTHCHECK, Docker hygiene
- README: version v0.2.6.8, corrected stale gate claim, updated changelog
- .env.example: merged two competing files into one, deleted bootstrap duplicate
- ErrorBoundary: new component wrapping app, prevents white-screen crashes
- Layout sidebar: version display from /api/health, Docs link to GitHub
- client-logger: debug/trace logger gated behind localStorage.redflag_debug=1,
  routes through existing /logs/client-error server endpoint (ETHOS #1)
- All web console.log calls rerouted through client-logger instead of deleted
- Server health endpoint returns version field
- Server accepts client_debug/client_trace in error_type validation
- Dockerfiles: pinned alpine:latest->3.21, nginx:alpine->1.27-alpine,
  added HEALTHCHECK directives
- docker-compose: healthcheck blocks for server and web services
- .dockerignore: created to slim Docker build context
2026-06-08 18:23:39 -04:00

562 lines
19 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"
"net/http"
"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"`
}
// 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"`
FixedVersion string `json:"fixed_version,omitempty"`
Published string `json:"published,omitempty"`
}
// 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,
}
// 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
}
}
}
// 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
}
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_vulns (available-version check)
// - "installed" → installed_checked_at / installed_vulns (installed-version threat check)
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 supply_chain_checked_at; a hit also records
// supply_chain_vulns; a query failure records supply_chain_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, vulnsKey, errorKey := osvMetaKeys(r.Namespace)
meta := map[string]interface{}{
checkedKey: now.Format(time.RFC3339),
errorKey: nil,
}
if len(result.Vulns) > 0 {
vulnJSON, err := json.Marshal(result.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(result.Vulns))
} 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)
}
}
}
// osvMetaKeys returns the metadata key names for a given check namespace.
func osvMetaKeys(namespace string) (checkedAt, vulns, checkError string) {
if namespace == "installed" {
return "installed_checked_at", "installed_vulns", "installed_check_error"
}
return "supply_chain_checked_at", "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 {
if len(batchResp.Results[i].Vulns) > 0 {
found = append(found, ClosureVuln{Name: e.Name, Version: e.Version, Vulns: batchResp.Results[i].Vulns})
log.Printf("[SECURITY] [supply_chain] closure_vuln pkg=%s version=%s ecosystem=%s count=%d",
e.Name, e.Version, ecosystem, len(batchResp.Results[i].Vulns))
}
}
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
}
if len(result.Vulns) == 0 {
return &SupplyChainCheckResult{
CheckedAt: time.Now().UTC(),
}
}
vulns := make([]VulnerabilityInfo, len(result.Vulns))
for i, v := range result.Vulns {
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).
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.
func NeedsCapabilityGate(pkgType string) bool {
switch pkgType {
case "dnf", "apt", "npm", "pypi":
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.
func EcosystemFromPackageType(pkgType string) string {
switch pkgType {
case "npm":
return "npm"
case "pypi":
return "PyPI"
case "apt":
return "Debian"
case "dnf":
return "AlmaLinux"
}
return pkgType
}