Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.6.2 — osv scans moved to detection, soak gate grew up

approval stopped re-scanning osv; it just reads what detection already
found. soak gate + age gate are real settings now (env→db→default), and
the dead soak-override column + table got composted.
This commit is contained in:
Fimeg 2026-06-07 14:44:41 -04:00
commit 5b1a16ca3e
13 changed files with 819 additions and 143 deletions

View file

@ -21,7 +21,7 @@ services:
context: .
dockerfile: ./server/Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-0.2.6.1}
BUILD_VERSION: ${BUILD_VERSION:-0.2.6.2}
container_name: redflag-server
volumes:
- server-config:/app/config

View file

@ -0,0 +1,294 @@
//go:build integration
package handlers
// reconcile_integration_test.go — DB-backed tests for RECONCILE-001 scan-set closure.
//
// These require a live PostgreSQL. Set TEST_DATABASE_URL to a *disposable* database
// (the suite runs all migrations and writes rows); the tests skip when it is unset:
//
// TEST_DATABASE_URL='postgres://user:pass@localhost:5432/redflag_test?sslmode=disable' \
// go test -tags=integration ./internal/api/handlers/ -run Integration -v
//
// Coverage (the positive paths the no-DB unit tests cannot reach):
// - closeScanAbsentRows: a waiting (pending) row absent from a successful scan closes
// to installed with provenance out_of_band.
// - Regression: a *stale* consumed capability token linked to the same row ID (from a
// prior lifecycle, preserved across the UPSERT key) does NOT flip provenance to
// redflag_receipt — closures are unconditionally out_of_band.
// - A row still present in the reported set is left untouched.
// - ReopenFailedUpdate is scoped to failed-only: it reopens failed rows and rejects
// installed / pending / ignored rows (the installed→pending edge must not leak in).
import (
"encoding/json"
"os"
"sync"
"testing"
"time"
"github.com/gofrs/uuid/v5"
"github.com/Fimeg/RedFlag/server/internal/database"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
)
var (
testDBOnce sync.Once
testDB *database.DB
testDBErr error
)
// getTestDB connects once per package run and applies all migrations. It skips the
// calling test when TEST_DATABASE_URL is unset so the suite is a no-op in CI lanes
// without a database.
func getTestDB(t *testing.T) *database.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; skipping DB-backed integration test")
}
testDBOnce.Do(func() {
testDB, testDBErr = database.Connect(dsn)
if testDBErr != nil {
return
}
// migrations live at server/internal/database/migrations; tests run from
// server/internal/api/handlers.
testDBErr = testDB.Migrate("../../database/migrations")
})
if testDBErr != nil {
t.Fatalf("test DB setup: %v", testDBErr)
}
return testDB
}
// seedAgent inserts a throwaway agent and registers cleanup (ON DELETE CASCADE
// removes its package rows, history, and tokens).
func seedAgent(t *testing.T, db *database.DB) uuid.UUID {
t.Helper()
id := uuid.Must(uuid.NewV4())
if _, err := db.Exec(
`INSERT INTO agents (id, hostname, os_type, agent_version) VALUES ($1, $2, 'linux', 'test')`,
id, "reconcile-it-"+id.String()); err != nil {
t.Fatalf("seed agent: %v", err)
}
t.Cleanup(func() { _, _ = db.Exec(`DELETE FROM agents WHERE id = $1`, id) })
return id
}
// seedPackage inserts one current_package_state row in the given status and returns its id.
func seedPackage(t *testing.T, db *database.DB, agentID uuid.UUID, name string, status models.PackageStatus, metadata models.JSONB) uuid.UUID {
t.Helper()
id := uuid.Must(uuid.NewV4())
meta := models.JSONB{}
if metadata != nil {
meta = metadata
}
metaJSON, err := json.Marshal(meta)
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
// repository_source is non-null: the row is loaded via SELECT * into
// models.UpdateState, whose RepositorySource is a plain string (NULL fails to scan).
if _, err := db.Exec(`
INSERT INTO current_package_state
(id, agent_id, package_type, package_name, current_version, available_version, severity, repository_source, status, metadata)
VALUES ($1, $2, 'dnf', $3, '1.0.0', '2.0.0', 'low', 'test-repo', $4, $5::jsonb)`,
id, agentID, name, status, string(metaJSON)); err != nil {
t.Fatalf("seed package %s (%s): %v", name, status, err)
}
return id
}
// seedConsumedToken links a CONSUMED capability token to updateID, simulating a token
// minted-and-consumed in a *prior* lifecycle that survived the UPSERT on the same row id.
func seedConsumedToken(t *testing.T, db *database.DB, agentID, updateID uuid.UUID) {
t.Helper()
now := time.Now().UTC()
if _, err := db.Exec(`
INSERT INTO capability_tokens
(token_id, update_id, agent_id, key_id, package_type, operation, closure,
issued_at, not_before, expires_at, signature, consumed_at)
VALUES ($1, $2, $3, 'testkey', 'dnf', 'install', '{}'::jsonb,
$4, $4, $5, 'sig', $6)`,
uuid.Must(uuid.NewV4()), updateID, agentID,
now.Add(-2*time.Hour).Unix(), now.Add(2*time.Hour).Unix(), now.Add(-time.Hour)); err != nil {
t.Fatalf("seed consumed token: %v", err)
}
}
// readPackage returns the current status and decoded live metadata for a row.
func readPackage(t *testing.T, db *database.DB, id uuid.UUID) (string, map[string]interface{}) {
t.Helper()
var status string
var metaRaw []byte
if err := db.QueryRow(`SELECT status, metadata FROM current_package_state WHERE id = $1`, id).
Scan(&status, &metaRaw); err != nil {
t.Fatalf("read package %s: %v", id, err)
}
meta := map[string]interface{}{}
if len(metaRaw) > 0 {
if err := json.Unmarshal(metaRaw, &meta); err != nil {
t.Fatalf("decode metadata: %v", err)
}
}
return status, meta
}
// latestHistoryMeta returns the decoded metadata of the most recent terminal-history
// row for a package, and whether any history row exists. Closure provenance is recorded
// here (update_version_history), not on the live current_package_state row — a terminal
// transition into installed merges meta into history, not back onto the live row.
func latestHistoryMeta(t *testing.T, db *database.DB, agentID uuid.UUID, packageName string) (map[string]interface{}, bool) {
t.Helper()
var metaRaw []byte
err := db.QueryRow(`
SELECT metadata FROM update_version_history
WHERE agent_id = $1 AND package_name = $2
ORDER BY update_completed_at DESC LIMIT 1`, agentID, packageName).Scan(&metaRaw)
if err != nil {
return nil, false // sql.ErrNoRows → no history row written
}
meta := map[string]interface{}{}
if len(metaRaw) > 0 {
if err := json.Unmarshal(metaRaw, &meta); err != nil {
t.Fatalf("decode history metadata: %v", err)
}
}
return meta, true
}
func newReconcileHandler(db *database.DB) *UpdateHandler {
h := NewUpdateHandler(
queries.NewUpdateQueries(db.DB),
queries.NewAgentQueries(db.DB),
nil, nil, nil, nil,
)
// Wire the token store directly (SetCapabilityMinter also requires a minter, which
// closeScanAbsentRows does not use). The whole point is to prove the closure ignores it.
h.tokenQueries = queries.NewCapabilityTokenQueries(db.DB)
return h
}
// TestCloseScanAbsentRows_StaleTokenStillOutOfBand_Integration is the regression for the
// provenance false-positive: a pending row that carries a stale consumed token (prior
// lifecycle) must still close as out_of_band, never redflag_receipt.
func TestCloseScanAbsentRows_StaleTokenStillOutOfBand_Integration(t *testing.T) {
db := getTestDB(t)
agentID := seedAgent(t, db)
h := newReconcileHandler(db)
rowID := seedPackage(t, db, agentID, "vim", models.StatusPending, nil)
seedConsumedToken(t, db, agentID, rowID) // stale token from a previous cycle
// Empty reported set → the pending row is absent → must close.
h.closeScanAbsentRows(agentID, "dnf", map[string]struct{}{})
if status, _ := readPackage(t, db, rowID); status != string(models.StatusInstalled) {
t.Fatalf("row should have closed to installed, got %q", status)
}
meta, ok := latestHistoryMeta(t, db, agentID, "vim")
if !ok {
t.Fatalf("expected a terminal-history row for the closure")
}
if got := meta["resolution_provenance"]; got != "out_of_band" {
t.Errorf("provenance = %v, want out_of_band (stale consumed token must NOT flip it to redflag_receipt)", got)
}
if got := meta["closed_by"]; got != "scan_set_reconciler" {
t.Errorf("closed_by = %v, want scan_set_reconciler", got)
}
}
// TestCloseScanAbsentRows_PresentRowUntouched_Integration verifies the diff: a row still
// in the reported set is kept; only the absent one closes.
func TestCloseScanAbsentRows_PresentRowUntouched_Integration(t *testing.T) {
db := getTestDB(t)
agentID := seedAgent(t, db)
h := newReconcileHandler(db)
keepID := seedPackage(t, db, agentID, "nano", models.StatusPending, nil)
closeID := seedPackage(t, db, agentID, "curl", models.StatusApproved, nil)
// nano is still reported; curl is absent.
h.closeScanAbsentRows(agentID, "dnf", map[string]struct{}{"nano": {}})
if status, _ := readPackage(t, db, keepID); status != string(models.StatusPending) {
t.Errorf("present row should stay pending, got %q", status)
}
if _, ok := latestHistoryMeta(t, db, agentID, "nano"); ok {
t.Errorf("present row must not produce a closure history row")
}
if status, _ := readPackage(t, db, closeID); status != string(models.StatusInstalled) {
t.Errorf("absent row should close to installed, got %q", status)
}
if meta, ok := latestHistoryMeta(t, db, agentID, "curl"); !ok {
t.Errorf("absent row should produce a closure history row")
} else if meta["resolution_provenance"] != "out_of_band" {
t.Errorf("absent row provenance = %v, want out_of_band", meta["resolution_provenance"])
}
}
// TestCloseScanAbsentRows_Idempotent_Integration verifies running closure 3× is safe
// (ETHOS §4): the row is closed once and re-runs are no-ops, not errors.
func TestCloseScanAbsentRows_Idempotent_Integration(t *testing.T) {
db := getTestDB(t)
agentID := seedAgent(t, db)
h := newReconcileHandler(db)
rowID := seedPackage(t, db, agentID, "wget", models.StatusPending, nil)
for i := 0; i < 3; i++ {
h.closeScanAbsentRows(agentID, "dnf", map[string]struct{}{})
}
if status, _ := readPackage(t, db, rowID); status != string(models.StatusInstalled) {
t.Errorf("after 3x close, status = %q, want installed", status)
}
}
// TestReopenFailedUpdate_ScopedToFailed_Integration verifies the requireFrom guard: a
// failed row reopens (and its failure markers clear), while installed / pending / ignored
// rows are rejected — the installed→pending edge (RECONCILE-001) must not leak into reopen.
func TestReopenFailedUpdate_ScopedToFailed_Integration(t *testing.T) {
db := getTestDB(t)
agentID := seedAgent(t, db)
uq := queries.NewUpdateQueries(db.DB)
t.Run("failed reopens and clears markers", func(t *testing.T) {
id := seedPackage(t, db, agentID, "pkg-failed", models.StatusFailed,
models.JSONB{"failure_reason": "boom", "failed_by": "operator", "dry_run_attempts": 2})
if err := uq.ReopenFailedUpdate(id); err != nil {
t.Fatalf("reopen of failed row should succeed: %v", err)
}
status, meta := readPackage(t, db, id)
if status != string(models.StatusPending) {
t.Errorf("status = %q, want pending", status)
}
for _, k := range []string{"failure_reason", "failed_by", "dry_run_attempts"} {
if _, ok := meta[k]; ok {
t.Errorf("metadata key %q should have been cleared, still present", k)
}
}
})
for _, tc := range []struct {
name string
status models.PackageStatus
}{
{"installed rejected", models.StatusInstalled},
{"pending rejected", models.StatusPending},
{"ignored rejected", models.StatusIgnored},
} {
t.Run(tc.name, func(t *testing.T) {
id := seedPackage(t, db, agentID, "pkg-"+string(tc.status), tc.status, nil)
if err := uq.ReopenFailedUpdate(id); err == nil {
t.Errorf("reopen of %s row must be rejected, got nil error", tc.status)
}
if status, _ := readPackage(t, db, id); status != string(tc.status) {
t.Errorf("status changed to %q, want unchanged %q", status, tc.status)
}
})
}
}

View file

@ -332,8 +332,10 @@ func scanEcosystemSupported(ecosystem string) bool {
// GetTrackedNonResting. In-flight states (checking_dependencies, pending_dependencies,
// installing) are owned by the orchestrator + capability-receipt path and are left alone,
// so the reconciler never races a RedFlag-driven install nor mislabels its provenance.
// A waiting row is out-of-band by construction (RedFlag had not begun executing it), so
// the provenance check below resolves to out_of_band unless a stray consumed token exists.
// A waiting row is out-of-band by construction (RedFlag had not begun executing it):
// capability tokens are only minted after checking_dependencies, a state a waiting row
// has not reached, so it can hold no active token for its current version. Closures here
// are therefore always stamped out_of_band — see the provenance note below.
//
// Safety invariants (ETHOS §3):
// - Only called when req.ScanSucceeded is true (caller enforces).
@ -361,22 +363,17 @@ func (h *UpdateHandler) closeScanAbsentRows(agentID uuid.UUID, ecosystem string,
continue
}
// Package absent from scan: determine provenance.
// A consumed capability token means RedFlag drove the install.
// Absence of any consumed token means it was resolved out-of-band.
provenance := "out_of_band"
if h.tokenQueries != nil {
if consumed, err := h.tokenQueries.HasConsumedTokenForUpdate(row.ID); err != nil {
log.Printf("[WARNING] [server] [reconcile] provenance_check_failed update_id=%s error=%v",
row.ID, err)
// Safe default: stay with out_of_band if we cannot confirm a receipt.
} else if consumed {
provenance = "redflag_receipt"
}
}
// Package absent from scan: this closure is out_of_band by construction.
// We deliberately do NOT consult consumed capability tokens here. The row ID
// is preserved across the UPSERT on (agent_id, package_type, package_name), so
// a consumed token from a *prior* lifecycle (when this row was last installed
// via the receipt path) stays linked to the same ID. A waiting-state row cannot
// have a token for its *current* version — tokens are minted only after
// checking_dependencies, which a waiting row has not reached — so any consumed
// token we'd find is stale and would mislabel a genuine out-of-band closure as
// redflag_receipt. Always stamp out_of_band.
meta := models.JSONB{
"resolution_provenance": provenance,
"resolution_provenance": "out_of_band",
"closed_by": "scan_set_reconciler",
"ecosystem": ecosystem,
"closed_at": time.Now().UTC().Format(time.RFC3339),
@ -390,8 +387,8 @@ func (h *UpdateHandler) closeScanAbsentRows(agentID uuid.UUID, ecosystem string,
continue
}
log.Printf("[INFO] [server] [reconcile] closed_by_absence update_id=%s package=%s/%s ecosystem=%s provenance=%s",
row.ID, row.PackageType, row.PackageName, ecosystem, provenance)
log.Printf("[INFO] [server] [reconcile] closed_by_absence update_id=%s package=%s/%s ecosystem=%s provenance=out_of_band",
row.ID, row.PackageType, row.PackageName, ecosystem)
// Emit a system event per closure for auditability (ETHOS §1, history table).
// Best-effort: a failure here must not rollback the transition already committed.
@ -404,8 +401,8 @@ func (h *UpdateHandler) closeScanAbsentRows(agentID uuid.UUID, ecosystem string,
Severity: models.SeverityInfo,
Component: models.ComponentServer,
Message: fmt.Sprintf(
"scan-set closure: %s/%s absent from %s scan, transitioned to installed (provenance: %s)",
row.PackageType, row.PackageName, ecosystem, provenance,
"scan-set closure: %s/%s absent from %s scan, transitioned to installed (provenance: out_of_band)",
row.PackageType, row.PackageName, ecosystem,
),
Metadata: meta,
CreatedAt: time.Now().UTC(),
@ -780,6 +777,78 @@ type supplyChainHold struct {
vulns []services.VulnerabilityInfo
}
// resolvePackageAgeGateConfig returns the approval-time package-age gate policy.
// It prefers the live settings service (env > config > DB > default, so a UI/DB
// override actually drives the gate) and falls back to the env-only
// PackageAgeGateConfig when no settings service is wired.
func (h *UpdateHandler) resolvePackageAgeGateConfig() (minAgeHours float64, enforcement string, blockUnknownAge bool) {
if h.securitySettings != nil {
return h.securitySettings.GetSupplyChainGateConfig()
}
minAgeHours, enforcement = services.PackageAgeGateConfig()
return
}
// resolveSoakGateConfig returns the install-path version-soak gate policy
// (GATE-005), preferring the live settings service (env > config > DB > default,
// so an admin policy drives the gate) and falling back to env-only SoakGateConfig
// when no settings service is wired.
func (h *UpdateHandler) resolveSoakGateConfig() (requiredDays float64, enforcement string) {
if h.securitySettings != nil {
return h.securitySettings.GetSoakGateConfig()
}
return services.SoakGateConfig()
}
// persistedSupplyChainVulns extracts the OSV vulnerabilities recorded on the
// update at *detection* time. OSV scanning runs on the scan-report path
// (enqueueOSVChecks), not at approval — the approval path only reads the verdict
// detection persisted into supply_chain_vulns metadata. Returns nil when none
// were recorded (clean, not-yet-checked, or non-supply-chain ecosystem).
func persistedSupplyChainVulns(update *models.UpdateState) []services.VulnerabilityInfo {
if update.Metadata == nil {
return nil
}
raw, ok := update.Metadata["supply_chain_vulns"].(string)
if !ok || strings.TrimSpace(raw) == "" {
return nil
}
var vulns []services.VulnerabilityInfo
if err := json.Unmarshal([]byte(raw), &vulns); err != nil {
log.Printf("[WARNING] [server] [supply_chain] persisted_vulns_unmarshal_failed id=%s error=%v", update.ID, err)
return nil
}
return vulns
}
// recordPackageAgeMetadata stamps the supply-chain age-gate verdict onto the
// update's metadata for auditability. A known publish date records the timestamp
// and age; a fail-closed-on-unknown block records the unknown marker so an
// operator can see why the install was held. A plain fail-open unknown (allowed)
// records nothing — there is no verdict worth persisting.
func recordPackageAgeMetadata(update *models.UpdateState, dec services.PackageAgeGateDecision) {
if dec.Unknown && !dec.ShouldBlock {
return
}
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
check := map[string]interface{}{
"min_age_hours": dec.MinAgeHours,
"enforcement": dec.Enforcement,
"blocked": dec.ShouldBlock,
"unknown": dec.Unknown,
}
if dec.Unknown {
check["blocked_unknown"] = dec.BlockedUnknown
} else {
update.Metadata["package_published_at"] = dec.PublishedAt.UTC().Format(time.RFC3339)
update.Metadata["package_age_hours"] = dec.AgeHours
check["source"] = dec.Source
}
update.Metadata["supply_chain_age_check"] = check
}
// evaluateSupplyChainHold decides whether a manual approval must full-stop before
// minting. A known vulnerability anywhere we have looked — the top-level package
// (this call or persisted) or any artifact in the resolved closure — blocks. For
@ -872,58 +941,43 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
return
}
// Run supply chain check for npm/PyPI packages
var vulns []services.VulnerabilityInfo
// OSV vulnerability data is produced at *detection* (enqueueOSVChecks on the
// scan-report path) and persisted to supply_chain_vulns metadata. Approval
// reads that persisted verdict — it does not re-scan OSV here.
vulns := persistedSupplyChainVulns(update)
var ageDecision services.PackageAgeGateDecision
if services.NeedsSupplyChainCheck(update.PackageType) {
ecosystem := services.EcosystemFromPackageType(update.PackageType)
result := services.CheckOSVVulnerabilities(update.PackageName, ecosystem, update.AvailableVersion)
if result != nil && len(result.Vulnerabilities) > 0 {
vulns = result.Vulnerabilities
log.Printf("[WARNING] [supply_chain] vulnerabilities_found id=%s pkg=%s count=%d",
id, update.PackageName, len(vulns))
vulnJSON, _ := json.Marshal(vulns)
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
update.Metadata["supply_chain_vulns"] = string(vulnJSON)
update.Metadata["supply_chain_checked_at"] = result.CheckedAt.Format(time.RFC3339)
}
// Time-delayed update gate (Shai-Hulud defense). Independent of OSV;
// runs even when there are no known CVEs because freshness itself is
// the signal — worm waves propagate inside the first-24h window.
minAgeHours, enforcement := services.PackageAgeGateConfig()
minAgeHours, enforcement, blockUnknownAge := h.resolvePackageAgeGateConfig()
if enforcement != "off" {
age := services.GetPackagePublishDate(update.PackageName, ecosystem, update.AvailableVersion)
ageDecision = services.EvaluatePackageAgeGate(age, minAgeHours, enforcement)
if !ageDecision.Unknown {
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
update.Metadata["package_published_at"] = ageDecision.PublishedAt.UTC().Format(time.RFC3339)
update.Metadata["package_age_hours"] = ageDecision.AgeHours
update.Metadata["supply_chain_age_check"] = map[string]interface{}{
"min_age_hours": ageDecision.MinAgeHours,
"enforcement": ageDecision.Enforcement,
"source": ageDecision.Source,
"blocked": ageDecision.ShouldBlock,
}
}
ageDecision = services.EvaluatePackageAgeGate(age, services.PackageAgeGatePolicy{
MinAgeHours: minAgeHours,
Enforcement: enforcement,
BlockUnknownAge: blockUnknownAge,
EcosystemAged: services.EcosystemSupportsPackageAge(ecosystem),
})
recordPackageAgeMetadata(update, ageDecision)
if ageDecision.ShouldBlock {
log.Printf("[WARNING] [supply_chain] approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f min=%.2f",
id, update.PackageName, ageDecision.AgeHours, ageDecision.MinAgeHours)
c.JSON(http.StatusConflict, gin.H{
log.Printf("[WARNING] [supply_chain] approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f min=%.2f unknown=%t",
id, update.PackageName, ageDecision.AgeHours, ageDecision.MinAgeHours, ageDecision.BlockedUnknown)
resp := gin.H{
"error": "approval blocked by supply chain age gate",
"reason": ageDecision.WarnMessage,
"package": update.PackageName,
"version": update.AvailableVersion,
"published_at": ageDecision.PublishedAt.UTC().Format(time.RFC3339),
"age_hours": ageDecision.AgeHours,
"min_age_hours": ageDecision.MinAgeHours,
"override_hint": "set REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT=warn (or off) and retry; or wait until threshold passes",
})
}
if !ageDecision.BlockedUnknown {
resp["published_at"] = ageDecision.PublishedAt.UTC().Format(time.RFC3339)
resp["age_hours"] = ageDecision.AgeHours
}
c.JSON(http.StatusConflict, resp)
return
}
}
@ -1490,7 +1544,7 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
approved := 0
// Bulk reads the gate config once — applies to every item in the batch.
gateMin, gateEnforcement := services.PackageAgeGateConfig()
gateMin, gateEnforcement, gateBlockUnknown := h.resolvePackageAgeGateConfig()
for _, idStr := range req.UpdateIDs {
id, err := uuid.FromString(idStr)
@ -1506,40 +1560,22 @@ func (h *UpdateHandler) ApproveUpdates(c *gin.Context) {
return
}
var vulns []services.VulnerabilityInfo
// OSV verdict comes from detection-time metadata (see persistedSupplyChainVulns
// and the single-approve path); bulk approval never re-scans OSV.
vulns := persistedSupplyChainVulns(update)
var ageDecision services.PackageAgeGateDecision
if services.NeedsSupplyChainCheck(update.PackageType) {
ecosystem := services.EcosystemFromPackageType(update.PackageType)
result := services.CheckOSVVulnerabilities(update.PackageName, ecosystem, update.AvailableVersion)
if result != nil && len(result.Vulnerabilities) > 0 {
vulns = result.Vulnerabilities
log.Printf("[WARNING] [supply_chain] vulnerabilities_found id=%s pkg=%s count=%d",
id, update.PackageName, len(vulns))
vulnJSON, _ := json.Marshal(vulns)
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
update.Metadata["supply_chain_vulns"] = string(vulnJSON)
update.Metadata["supply_chain_checked_at"] = result.CheckedAt.Format(time.RFC3339)
}
if gateEnforcement != "off" {
age := services.GetPackagePublishDate(update.PackageName, ecosystem, update.AvailableVersion)
ageDecision = services.EvaluatePackageAgeGate(age, gateMin, gateEnforcement)
if !ageDecision.Unknown {
if update.Metadata == nil {
update.Metadata = make(models.JSONB)
}
update.Metadata["package_published_at"] = ageDecision.PublishedAt.UTC().Format(time.RFC3339)
update.Metadata["package_age_hours"] = ageDecision.AgeHours
update.Metadata["supply_chain_age_check"] = map[string]interface{}{
"min_age_hours": ageDecision.MinAgeHours,
"enforcement": ageDecision.Enforcement,
"source": ageDecision.Source,
"blocked": ageDecision.ShouldBlock,
}
}
ageDecision = services.EvaluatePackageAgeGate(age, services.PackageAgeGatePolicy{
MinAgeHours: gateMin,
Enforcement: gateEnforcement,
BlockUnknownAge: gateBlockUnknown,
EcosystemAged: services.EcosystemSupportsPackageAge(ecosystem),
})
recordPackageAgeMetadata(update, ageDecision)
if ageDecision.ShouldBlock {
log.Printf("[WARNING] [supply_chain] bulk_approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f",
id, update.PackageName, ageDecision.AgeHours)
@ -1739,7 +1775,7 @@ func (h *UpdateHandler) InstallVersion(c *gin.Context) {
}
// Evaluate soak gate.
requiredDays, enforcement := services.SoakGateConfig()
requiredDays, enforcement := h.resolveSoakGateConfig()
decision := services.EvaluateSoakGate(pv.FirstScannedAt, requiredDays, enforcement)
if !decision.Eligible && !decision.Unknown {

View file

@ -0,0 +1,26 @@
-- Reverse of 053: restore the soak-override scaffolding from migration 050.
ALTER TABLE current_package_state
ADD COLUMN IF NOT EXISTS soak_window_hours_override NUMERIC;
CREATE TABLE IF NOT EXISTS version_soak_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
update_id UUID NOT NULL,
agent_id UUID NOT NULL,
package_type VARCHAR(32) NOT NULL,
package_name TEXT NOT NULL,
version TEXT NOT NULL,
soak_days_remaining NUMERIC NOT NULL,
override_reason TEXT NOT NULL,
overridden_by TEXT NOT NULL,
overridden_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_soak_overrides_update
ON version_soak_overrides (update_id);
CREATE INDEX IF NOT EXISTS idx_soak_overrides_agent
ON version_soak_overrides (agent_id);
CREATE INDEX IF NOT EXISTS idx_soak_overrides_time
ON version_soak_overrides (overridden_at DESC);

View file

@ -0,0 +1,16 @@
-- GATE-005 cleanup: retire dead soak-override scaffolding.
--
-- Migration 050 added a per-package `soak_window_hours_override` column and a
-- `version_soak_overrides` table, but neither was ever wired: the column was
-- loaded and never read, and no query ever touched the table. The soak gate is
-- now configured as a proper security setting (supply_chain.soak_window_days /
-- soak_enforcement, admin-settable with defaults) and operator overrides are
-- journaled to system_events alongside every other gate override. Drop the
-- unused scaffolding so the schema reflects what the code actually does.
--
-- `selected_version` (also from 050) is kept — it is live (SetTargetVersion).
DROP TABLE IF EXISTS version_soak_overrides;
ALTER TABLE current_package_state
DROP COLUMN IF EXISTS soak_window_hours_override;

View file

@ -169,23 +169,14 @@ func (q *CapabilityTokenQueries) IsConsumed(tokenID uuid.UUID) (bool, error) {
return consumed.Valid, nil
}
// HasConsumedTokenForUpdate reports whether a capability token was minted and
// successfully consumed for the given update row. A consumed token means RedFlag
// drove the install (via the helper executor). Used by the scan-set reconciler
// (RECONCILE-001) to determine resolution provenance:
//
// consumed token → resolution_provenance = "redflag_receipt"
// no consumed token → resolution_provenance = "out_of_band"
func (q *CapabilityTokenQueries) HasConsumedTokenForUpdate(updateID uuid.UUID) (bool, error) {
var count int
err := q.db.Get(&count, `
SELECT COUNT(*) FROM capability_tokens
WHERE update_id = $1 AND consumed_at IS NOT NULL`, updateID)
if err != nil {
return false, fmt.Errorf("has_consumed_token_for_update update_id=%s: %w", updateID, err)
}
return count > 0, nil
}
// NOTE: HasConsumedTokenForUpdate was removed deliberately. The scan-set reconciler
// (RECONCILE-001) used to call it to label out-of-band closures as redflag_receipt
// when a consumed token existed, but the row ID is preserved across the UPSERT key
// (agent_id, package_type, package_name), so a token consumed in a *prior* lifecycle
// stays linked to the same ID and produced false redflag_receipt provenance on what
// were genuinely out-of-band closures of a new version. Waiting-state rows can hold
// no token for their current version (tokens mint only after checking_dependencies),
// so the reconciler now unconditionally stamps out_of_band — no per-row token lookup.
// TokenStatusHistory is one row of the token lifecycle visible to operators.
type TokenStatusHistory struct {

View file

@ -501,6 +501,12 @@ func (s statusSelector) String() string {
type transitionOpts struct {
completedAt *time.Time
historyMeta models.JSONB // nil → fall back to the row's current metadata
// requireFrom, when non-empty, constrains the transition to rows currently in
// this exact state. The state machine alone is too permissive for some callers:
// e.g. installed → pending is a valid edge (scan-set reactivation), but the
// "reopen a *failed* update" operation must not silently act on installed rows.
// The check is atomic with the guarded UPDATE — both pin status = cur.Status.
requireFrom models.PackageStatus
}
// transitionStatus is the validated, race-guarded core. It loads the selected row,
@ -518,6 +524,11 @@ func (q *UpdateQueries) transitionStatus(tx *sqlx.Tx, sel statusSelector, to mod
return cur, fmt.Errorf("transition %s: %w", sel, err)
}
if opts.requireFrom != "" && cur.Status != opts.requireFrom {
return cur, fmt.Errorf("transition %s -> %q: requires current state %q, got %q",
sel, to, opts.requireFrom, cur.Status)
}
// Guarded UPDATE — $1 is the new status, the trailing placeholder re-checks the
// status we observed so a racing caller that already advanced the row yields rows == 0.
// On a transition *into* failed we also merge the failure metadata (failure_reason,
@ -580,13 +591,20 @@ func (q *UpdateQueries) recordTerminalHistory(tx *sqlx.Tx, cur models.UpdateStat
// runTransition opens a transaction, applies a single transition, runs an optional
// follow-up write in the same transaction, and commits.
func (q *UpdateQueries) runTransition(sel statusSelector, to models.PackageStatus, after func(tx *sqlx.Tx, cur models.UpdateState) error) error {
return q.runTransitionOpts(sel, to, transitionOpts{}, after)
}
// runTransitionOpts is runTransition with caller-supplied transitionOpts (e.g. a
// requireFrom precondition). The precondition is validated inside the same
// transaction as the transition, so it is atomic with the guarded UPDATE.
func (q *UpdateQueries) runTransitionOpts(sel statusSelector, to models.PackageStatus, opts transitionOpts, after func(tx *sqlx.Tx, cur models.UpdateState) error) error {
tx, err := q.db.Beginx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
cur, err := q.transitionStatus(tx, sel, to, transitionOpts{})
cur, err := q.transitionStatus(tx, sel, to, opts)
if err != nil {
return err
}
@ -721,10 +739,14 @@ func (q *UpdateQueries) RejectUpdateByPackage(agentID uuid.UUID, packageType, pa
// back to pending and clearing the live failure markers from the row. The
// failure record itself stays in update_version_history — this only resets the
// actionable state so the normal approve -> dry-run -> install lifecycle (which
// re-checks whether the update still applies) can run again. The state machine
// rejects this unless the package is currently failed.
// re-checks whether the update still applies) can run again.
//
// Scoped to failed-only via requireFrom: the state machine permits installed ->
// pending (scan-set reactivation, RECONCILE-001), so the generic pending edge is
// not enough to keep this operation off installed rows. requireFrom pins it to
// rows that are actually failed.
func (q *UpdateQueries) ReopenFailedUpdate(id uuid.UUID) error {
return q.runTransition(selByID(id), models.StatusPending, func(tx *sqlx.Tx, _ models.UpdateState) error {
return q.runTransitionOpts(selByID(id), models.StatusPending, transitionOpts{requireFrom: models.StatusFailed}, func(tx *sqlx.Tx, _ models.UpdateState) error {
_, err := tx.Exec(
`UPDATE current_package_state
SET metadata = COALESCE(metadata, '{}'::jsonb) - 'failure_reason' - 'failed_by' - 'dry_run_attempts'

View file

@ -150,8 +150,7 @@ type UpdateState struct {
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Status PackageStatus `json:"status" db:"status"`
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
SelectedVersion *string `json:"selected_version,omitempty" db:"selected_version"` // GATE-005: soak-gate target
SoakWindowHoursOverride *float64 `json:"soak_window_hours_override,omitempty" db:"soak_window_hours_override"` // GATE-005: per-package override
SelectedVersion *string `json:"selected_version,omitempty" db:"selected_version"` // GATE-005: soak-gate target
// Enrichment fields — populated from Metadata by EnrichFromMetadata().
// Not persisted; db:"-" excludes them from SQL scans.

View file

@ -135,14 +135,47 @@ func fetchPyPIPublishDate(pkgName, version string) *PackageAgeResult {
// PackageAgeGateDecision is the verdict the approve handler uses to decide
// whether to block, warn, or pass an approval.
type PackageAgeGateDecision struct {
PublishedAt time.Time
AgeHours float64
MinAgeHours float64
Enforcement string // "off" | "warn" | "block"
ShouldBlock bool
WarnMessage string
Source string
Unknown bool // true when we couldn't determine the publish date — caller treats as allow
PublishedAt time.Time
AgeHours float64
MinAgeHours float64
Enforcement string // "off" | "warn" | "block"
ShouldBlock bool
BlockedUnknown bool // true when the block was caused by fail-closed-on-unknown, not by freshness
WarnMessage string
Source string
Unknown bool // true when we couldn't determine the publish date
}
// PackageAgeGatePolicy is the resolved configuration the gate evaluates against.
// It is computed once per request (env / config / DB / default) and passed into
// EvaluatePackageAgeGate so the decision logic stays pure and testable.
type PackageAgeGatePolicy struct {
MinAgeHours float64
Enforcement string // "off" | "warn" | "block"
// BlockUnknownAge turns the unknown-age path fail-*closed* instead of the
// default fail-open. It only bites when Enforcement == "block" AND the
// ecosystem actually has a recency source (EcosystemAged) — so a registry
// we *expected* to answer but couldn't is treated as suspicious, while a
// distro package that legitimately has no publish date is never blocked.
BlockUnknownAge bool
// EcosystemAged is true for ecosystems where GetPackagePublishDate can
// produce a real answer (npm, PyPI). For these, a nil result is anomalous
// rather than merely "unsupported", which is what makes fail-closed safe.
EcosystemAged bool
}
// EcosystemSupportsPackageAge reports whether GetPackagePublishDate has a
// recency source for the given OSV ecosystem. Only these ecosystems are
// eligible for fail-closed-on-unknown — for everything else a nil age means
// "no source", not "source went dark", and must never block.
func EcosystemSupportsPackageAge(ecosystem string) bool {
switch ecosystem {
case "npm", "PyPI":
return true
}
return false
}
// PackageAgeGateConfig reads the supply_chain gate config. For v0.2.0.0 this
@ -172,31 +205,40 @@ func PackageAgeGateConfig() (minAgeHours float64, enforcement string) {
}
// EvaluatePackageAgeGate computes the gate decision for a single package
// version against the configured min-age threshold. Pure logic, no HTTP — the
// fetch happens once at the call site and the result is passed in.
func EvaluatePackageAgeGate(age *PackageAgeResult, minAgeHours float64, enforcement string) PackageAgeGateDecision {
// version against the resolved policy. Pure logic, no HTTP — the fetch happens
// once at the call site and the result is passed in.
func EvaluatePackageAgeGate(age *PackageAgeResult, policy PackageAgeGatePolicy) PackageAgeGateDecision {
dec := PackageAgeGateDecision{
MinAgeHours: minAgeHours,
Enforcement: enforcement,
MinAgeHours: policy.MinAgeHours,
Enforcement: policy.Enforcement,
}
if age == nil {
dec.Unknown = true
// Fail-closed only when explicitly opted in, under block enforcement,
// and for an ecosystem we expected to answer. Otherwise unknown age is
// allowed — infrastructure hiccups must not gate legitimate work, and a
// distro package with no registry recency source is normal, not suspect.
if policy.Enforcement == "block" && policy.BlockUnknownAge && policy.EcosystemAged {
dec.ShouldBlock = true
dec.BlockedUnknown = true
dec.WarnMessage = "could not determine the publish date of a registry-backed package; failing closed (block_unknown_age) — a dark recency source is itself a Shai-Hulud-class signal"
}
return dec
}
dec.PublishedAt = age.PublishedAt
dec.Source = age.Source
dec.AgeHours = time.Since(age.PublishedAt).Hours()
if dec.AgeHours >= minAgeHours {
if dec.AgeHours >= policy.MinAgeHours {
return dec
}
// Below the threshold — surface a clear message either way.
dec.WarnMessage = fmt.Sprintf(
"package published %.1fh ago (threshold %.1fh) — Shai-Hulud-class supply chain attacks propagate inside this window",
dec.AgeHours, minAgeHours,
dec.AgeHours, policy.MinAgeHours,
)
if enforcement == "block" {
if policy.Enforcement == "block" {
dec.ShouldBlock = true
}
return dec

View file

@ -18,10 +18,13 @@ func TestEvaluatePackageAgeGate_BelowThresholdBlock(t *testing.T) {
PublishedAt: time.Now().Add(-2 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "block")
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "block"})
if !dec.ShouldBlock {
t.Errorf("expected block, got pass; decision=%+v", dec)
}
if dec.BlockedUnknown {
t.Error("a freshness block must not be flagged as a blocked-unknown")
}
if dec.WarnMessage == "" {
t.Error("expected warn message even on block path; got empty")
}
@ -35,7 +38,7 @@ func TestEvaluatePackageAgeGate_BelowThresholdWarn(t *testing.T) {
PublishedAt: time.Now().Add(-2 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "warn")
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "warn"})
if dec.ShouldBlock {
t.Errorf("warn enforcement must not block; decision=%+v", dec)
}
@ -49,7 +52,7 @@ func TestEvaluatePackageAgeGate_AboveThreshold(t *testing.T) {
PublishedAt: time.Now().Add(-72 * time.Hour),
Source: "pypi.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "block")
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "block"})
if dec.ShouldBlock {
t.Errorf("above-threshold package must not be blocked; decision=%+v", dec)
}
@ -58,37 +61,102 @@ func TestEvaluatePackageAgeGate_AboveThreshold(t *testing.T) {
}
}
func TestEvaluatePackageAgeGate_UnknownAgeIsAllow(t *testing.T) {
// Fail-open: when the registry probe couldn't tell us when the package
// was published (network, ecosystem unsupported, version missing), we
// must NOT block — infrastructure issues should never gate legitimate
// approvals. This is part of the sovereignty contract.
dec := EvaluatePackageAgeGate(nil, 24.0, "block")
func TestEvaluatePackageAgeGate_UnknownAgeIsAllowByDefault(t *testing.T) {
// Fail-open by default: when the registry probe couldn't tell us when the
// package was published (network, ecosystem unsupported, version missing),
// and the operator has NOT opted into fail-closed, we must NOT block —
// infrastructure issues should never gate legitimate approvals. Sovereignty.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "block"})
if !dec.Unknown {
t.Error("expected unknown=true when age is nil")
}
if dec.ShouldBlock {
t.Error("unknown age must not trigger block even under block enforcement")
t.Error("unknown age must not trigger block when block_unknown_age is off")
}
if dec.WarnMessage != "" {
t.Errorf("unknown age must not produce a warning message; got %q", dec.WarnMessage)
t.Errorf("allowed-unknown must not produce a warning message; got %q", dec.WarnMessage)
}
}
func TestEvaluatePackageAgeGate_UnknownAgeFailsClosedWhenOptedIn(t *testing.T) {
// Opt-in fail-closed: block enforcement + block_unknown_age + an ecosystem
// that *should* have answered (npm/PyPI). A dark recency source for a
// registry-backed package is itself a worm signal.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{
MinAgeHours: 24.0,
Enforcement: "block",
BlockUnknownAge: true,
EcosystemAged: true,
})
if !dec.Unknown {
t.Error("expected unknown=true when age is nil")
}
if !dec.ShouldBlock {
t.Error("unknown age must fail closed when block_unknown_age is on for an aged ecosystem")
}
if !dec.BlockedUnknown {
t.Error("expected BlockedUnknown=true on the fail-closed path")
}
if dec.WarnMessage == "" {
t.Error("fail-closed block must carry a reason message")
}
}
func TestEvaluatePackageAgeGate_UnknownAgeAllowedForNonAgedEcosystem(t *testing.T) {
// Even with block_unknown_age on, an ecosystem with no recency source
// (apt/dnf) must never be blocked on unknown — there's nothing dark about
// a distro package having no upstream publish timestamp.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{
MinAgeHours: 24.0,
Enforcement: "block",
BlockUnknownAge: true,
EcosystemAged: false,
})
if dec.ShouldBlock {
t.Error("non-aged ecosystem must not fail closed on unknown even when opted in")
}
}
func TestEvaluatePackageAgeGate_UnknownAgeAllowedUnderWarnEvenIfOptedIn(t *testing.T) {
// block_unknown_age only bites under "block" enforcement — under "warn" a
// dark source is surfaced via logs/metadata, never a hard stop.
dec := EvaluatePackageAgeGate(nil, PackageAgeGatePolicy{
MinAgeHours: 24.0,
Enforcement: "warn",
BlockUnknownAge: true,
EcosystemAged: true,
})
if dec.ShouldBlock {
t.Error("warn enforcement must not fail closed on unknown")
}
}
func TestEvaluatePackageAgeGate_OffMeansOff(t *testing.T) {
// "off" enforcement should not be reached by the gate (caller checks
// PackageAgeGateConfig and skips), but if it is, behavior must be safe:
// no block, no warn. Treat as a degenerate case of warn-without-message.
// config and skips), but if it is, behavior must be safe: no block, no warn.
age := &PackageAgeResult{
PublishedAt: time.Now().Add(-1 * time.Hour),
Source: "registry.npmjs.org",
}
dec := EvaluatePackageAgeGate(age, 24.0, "off")
dec := EvaluatePackageAgeGate(age, PackageAgeGatePolicy{MinAgeHours: 24.0, Enforcement: "off"})
if dec.ShouldBlock {
t.Error("off enforcement must not block even below threshold")
}
}
func TestEcosystemSupportsPackageAge(t *testing.T) {
for _, eco := range []string{"npm", "PyPI"} {
if !EcosystemSupportsPackageAge(eco) {
t.Errorf("%s should support package age", eco)
}
}
for _, eco := range []string{"Debian", "AlmaLinux", "", "crates.io"} {
if EcosystemSupportsPackageAge(eco) {
t.Errorf("%s should not support package age", eco)
}
}
}
func TestPackageAgeGateConfig_Defaults(t *testing.T) {
// Env vars cleared in this test process should yield defaults.
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "")

View file

@ -267,6 +267,51 @@ func (s *SecuritySettingsService) ValidateSetting(category, key string, value in
return fmt.Errorf("backoff_max_seconds must be a number")
}
case "supply_chain.min_package_age_hours":
if hours, ok := value.(float64); ok {
if hours < 0 || hours > 8760 {
return fmt.Errorf("min_package_age_hours must be between 0 and 8760 (one year)")
}
} else {
return fmt.Errorf("min_package_age_hours must be a number")
}
case "supply_chain.gate_enforcement":
if mode, ok := value.(string); ok {
switch strings.ToLower(mode) {
case "off", "warn", "block":
default:
return fmt.Errorf("gate_enforcement must be one of: off, warn, block")
}
} else {
return fmt.Errorf("gate_enforcement must be a string")
}
case "supply_chain.block_unknown_age":
if _, ok := value.(bool); !ok {
return fmt.Errorf("block_unknown_age must be a boolean")
}
case "supply_chain.soak_window_days":
if days, ok := value.(float64); ok {
if days < 0 || days > 365 {
return fmt.Errorf("soak_window_days must be between 0 and 365")
}
} else {
return fmt.Errorf("soak_window_days must be a number")
}
case "supply_chain.soak_enforcement":
if mode, ok := value.(string); ok {
switch strings.ToLower(mode) {
case "off", "warn", "block":
default:
return fmt.Errorf("soak_enforcement must be one of: off, warn, block")
}
} else {
return fmt.Errorf("soak_enforcement must be a string")
}
case "command_signing.algorithm", "update_signing.algorithm":
if algo, ok := value.(string); ok {
if algo != "ed25519" {
@ -343,6 +388,19 @@ func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]int
"supply_chain": {
"min_package_age_hours": 24.0,
"gate_enforcement": "warn",
// block_unknown_age fails the gate *closed* when a registry-backed
// package (npm/PyPI) cannot be dated — a dark recency source is
// itself a worm signal. Default false preserves the sovereignty
// principle (infra hiccups never block legitimate work); operators
// running a hostile-supply-chain posture can opt in.
"block_unknown_age": false,
// Version soak gate (GATE-005), applied on the install path. A
// version must have been observed in the fleet for soak_window_days
// before it is install-eligible; soak_enforcement chooses off/warn/
// block. Default 14d/block holds the "newest version" signal that
// zero-day supply-chain attacks ride on.
"soak_window_days": 14.0,
"soak_enforcement": "block",
},
}
}
@ -503,6 +561,63 @@ func (s *SecuritySettingsService) GetPolicyString(key, fallback string) string {
return fallback
}
// GetSupplyChainGateConfig resolves the approval-time package-age gate policy
// from the layered settings (env > config > DB > baked default, via GetSetting).
// This is what makes the supply_chain.* settings live: PackageAgeGateConfig()
// alone reads only env, so a DB/UI override would otherwise be inert. Callers
// that hold a settings service should prefer this; the free PackageAgeGateConfig
// remains the fallback when no service is wired.
func (s *SecuritySettingsService) GetSupplyChainGateConfig() (minAgeHours float64, enforcement string, blockUnknownAge bool) {
// Start from the env/baked defaults so a missing service or DB row degrades
// to exactly the legacy behavior.
minAgeHours, enforcement = PackageAgeGateConfig()
blockUnknownAge = false
if v, err := s.GetSetting("supply_chain", "min_package_age_hours"); err == nil {
if f, ok := v.(float64); ok && f >= 0 {
minAgeHours = f
}
}
if v, err := s.GetSetting("supply_chain", "gate_enforcement"); err == nil {
if str, ok := v.(string); ok {
switch strings.ToLower(str) {
case "off", "warn", "block":
enforcement = strings.ToLower(str)
}
}
}
if v, err := s.GetSetting("supply_chain", "block_unknown_age"); err == nil {
if b, ok := v.(bool); ok {
blockUnknownAge = b
}
}
return
}
// GetSoakGateConfig resolves the install-path version-soak gate policy (GATE-005)
// from the layered settings (env > config > DB > baked default, via GetSetting),
// so an admin's DB/UI policy actually drives the gate. SoakGateConfig() alone
// reads only env; this is the live equivalent and the free function remains the
// fallback when no settings service is wired.
func (s *SecuritySettingsService) GetSoakGateConfig() (requiredDays float64, enforcement string) {
requiredDays, enforcement = SoakGateConfig()
if v, err := s.GetSetting("supply_chain", "soak_window_days"); err == nil {
if f, ok := v.(float64); ok && f >= 0 {
requiredDays = f
}
}
if v, err := s.GetSetting("supply_chain", "soak_enforcement"); err == nil {
if str, ok := v.(string); ok {
switch strings.ToLower(str) {
case "off", "warn", "block":
enforcement = strings.ToLower(str)
}
}
}
return
}
// GetOperationalInt reads an operational.<key> setting as int. On error or
// type mismatch returns the supplied fallback. Used for runtime tuning where
// a missing row should not stop the service.

View file

@ -0,0 +1,67 @@
package services
import "testing"
// GetSupplyChainGateConfig layers env > config > DB > default via GetSetting.
// The env path short-circuits before any DB access, so we can exercise the
// resolver — and the fact that it makes the DB-backed settings *live* at all —
// without a database by setting the REDFLAG_SUPPLY_CHAIN_* env vars, which
// getEnvironmentValue maps to the supply_chain.* keys.
func TestGetSupplyChainGateConfig_EnvDrivesGate(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "48")
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "block")
t.Setenv("REDFLAG_SUPPLY_CHAIN_BLOCK_UNKNOWN_AGE", "true")
s := &SecuritySettingsService{} // settingsQueries nil: env path must not touch it
minAge, enf, blockUnknown := s.GetSupplyChainGateConfig()
if minAge != 48.0 {
t.Errorf("min age: want 48, got %v", minAge)
}
if enf != "block" {
t.Errorf("enforcement: want block, got %q", enf)
}
if !blockUnknown {
t.Error("block_unknown_age: want true")
}
}
func TestGetSupplyChainGateConfig_InvalidEnforcementIgnored(t *testing.T) {
// A bad enforcement env value falls back to the baked default rather than
// driving the gate into an undefined state. (min/block set to valid env
// values so every key resolves on the env path and never touches the DB.)
t.Setenv("REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT", "panic")
t.Setenv("REDFLAG_SUPPLY_CHAIN_MIN_PACKAGE_AGE_HOURS", "24")
t.Setenv("REDFLAG_SUPPLY_CHAIN_BLOCK_UNKNOWN_AGE", "false")
s := &SecuritySettingsService{}
_, enf, _ := s.GetSupplyChainGateConfig()
if enf != "warn" {
t.Errorf("invalid enforcement should fall back to warn, got %q", enf)
}
}
func TestGetSoakGateConfig_EnvDrivesGate(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_WINDOW_DAYS", "7")
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_ENFORCEMENT", "warn")
s := &SecuritySettingsService{} // env path must not touch the nil DB
days, enf := s.GetSoakGateConfig()
if days != 7.0 {
t.Errorf("soak window: want 7, got %v", days)
}
if enf != "warn" {
t.Errorf("soak enforcement: want warn, got %q", enf)
}
}
func TestGetSoakGateConfig_InvalidEnforcementIgnored(t *testing.T) {
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_WINDOW_DAYS", "14")
t.Setenv("REDFLAG_SUPPLY_CHAIN_SOAK_ENFORCEMENT", "nonsense")
s := &SecuritySettingsService{}
_, enf := s.GetSoakGateConfig()
if enf != "block" {
t.Errorf("invalid soak enforcement should fall back to the block default, got %q", enf)
}
}

View file

@ -12,8 +12,8 @@ import (
// Build-time injected version information (SERVER AUTHORITY)
var (
AgentVersion = "0.2.6.1"
ConfigVersion = "0.2.6.1"
AgentVersion = "0.2.6.2"
ConfigVersion = "0.2.6.2"
MinAgentVersion = "0.1.22"
)