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:
parent
b82649967e
commit
5b1a16ca3e
13 changed files with 819 additions and 143 deletions
|
|
@ -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
|
||||
|
|
|
|||
294
server/internal/api/handlers/reconcile_integration_test.go
Normal file
294
server/internal/api/handlers/reconcile_integration_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
67
server/internal/services/supply_chain_gate_config_test.go
Normal file
67
server/internal/services/supply_chain_gate_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue