crypto: forward-only key-path ceiling + OSV resilience + token serialization
SEC-028 -- a rotated-out server signing key must stop being trusted even when the agent cannot phone home. pubkey.go: bounded stale-cache window on public-key fetch failure; past the window (or when cache age is unknown) it fails closed instead of trusting the cached key indefinitely. Window length is operator policy (command_signing.stale_key_max_age_hours, default 168h/7d) delivered fleet-wide via GET /agents/:id/config; the [1h, 30d] clamp and the existence of the ceiling are doctrine, not knobs. verification.go: CheckKeyRotation refuses when the named key_id is not in the server active set (no primary fallback), and applies the same bounded-stale ceiling to the active-set fetch-failure path so key_id'd commands are no weaker than keyless ones. Server carries the default + 1-720h validation; web surfaces it in Security Settings. SEC-029 -- the standalone OSV.dev client retries transient transport/5xx/429 with exponential backoff and trips a process-wide circuit breaker after a run of failures, fast-failing to 'unreachable'. Verdict semantics unchanged and still fail-closed; the resilience only stops a transient scanner blip from forcing an operator override. GATE-004 #4 -- Consumer.ProcessToken holds a mutex so the replay-state guards are never raced by a concurrent caller. Today's single caller (the poll loop) never overlaps; this enforces the one-token-at-a-time invariant for future callers (local-API trigger, retry worker). RAF/verification/03 and RAF/security/05 document the key-path and OSV changes. ETHOS #3, #4; forward-only doctrine.
This commit is contained in:
parent
e0765c29f4
commit
0b1b8124b0
13 changed files with 374 additions and 57 deletions
|
|
@ -289,6 +289,16 @@ Approval *reads* the persisted verdict — it does not re-scan. Any known vulner
|
|||
anywhere in the closure is a full stop (see Enforcement Posture below); there is no
|
||||
severity threshold below which approval proceeds quietly.
|
||||
|
||||
**Standalone path resilience.** In standalone mode there is no server, so the agent
|
||||
queries OSV.dev directly before requesting a mint (`agent/internal/supplychain/osv.go`).
|
||||
That client retries transient failures (transport error, 5xx, 429) with exponential
|
||||
backoff and trips a process-wide circuit breaker after a run of failures, fast-failing
|
||||
to `unreachable` instead of hammering the endpoint (ETHOS #3, SEC-029). The verdict
|
||||
semantics are unchanged and remain fail-closed: an exhausted retry or an open breaker
|
||||
surfaces as `unreachable` — never a silent clear — and `unreachable` still gates the
|
||||
mint behind an explicit operator override. The resilience only avoids turning a transient
|
||||
OSV blip into a forced operator action.
|
||||
|
||||
### 6. SLSA/Sigstore Attestation (Visibility Signal)
|
||||
|
||||
**Not a hard block** — surfaced as a visibility indicator.
|
||||
|
|
|
|||
|
|
@ -86,6 +86,36 @@ RedFlag supports rotating the server's Ed25519 signing key while maintaining age
|
|||
- Force agents to re-fetch public key list
|
||||
- Affected commands can be replayed until key revocation propagates
|
||||
|
||||
### Forward-Only Enforcement at the Agent Key Path
|
||||
|
||||
A rotated-out or revoked key must stop being trusted. Two agent-side rules make
|
||||
that hold even when the agent cannot reach the server (SEC-028):
|
||||
|
||||
- **Bounded stale cache.** When the public-key fetch fails (network down), the
|
||||
agent keeps serving its last cached key only within a bounded staleness
|
||||
window. Past the window — or when the cache age cannot be established (no
|
||||
metadata sidecar) — it fails closed rather than trusting a possibly
|
||||
rotated-out key indefinitely. Acceptance within the window is surfaced at
|
||||
ERROR, not WARNING: it is a degraded-trust state, not routine.
|
||||
- The **window length** is operator policy: security setting
|
||||
`command_signing.stale_key_max_age_hours` (default 168h / 7d), delivered
|
||||
fleet-wide via `GET /api/v1/agents/:id/config` and overridable per
|
||||
deployment for sites with long offline windows.
|
||||
- The **existence of a fail-closed ceiling is doctrine, not a knob.** The
|
||||
agent clamps any configured value to `[1h, 30d]` (`SetStaleKeyMaxAge`) and
|
||||
the server rejects out-of-range writes (1–720h validation). No setting and
|
||||
no tampered local config can disable the ceiling or set it to infinite.
|
||||
|
||||
- **Active-set refusal.** When a command names a `key_id` the server's active
|
||||
set does not contain, the agent refuses verification (returns an error that
|
||||
the command handler surfaces as a verification failure) instead of falling
|
||||
back to the primary cached key. A key the server has rotated out is dead.
|
||||
|
||||
**Implementation:**
|
||||
- `agent/internal/crypto/pubkey.go` (`FetchAndCacheServerPublicKey`, `SetStaleKeyMaxAge`)
|
||||
- `agent/internal/crypto/verification.go:CheckKeyRotation()`
|
||||
- `server/internal/services/security_settings_service.go` (`command_signing.stale_key_max_age_hours` default + validation)
|
||||
|
||||
### TOFU Limitations
|
||||
- Compromised initial key → all future keys trusted
|
||||
- Mitigation: Monitor agent registration patterns
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/client"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/crypto"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/desktop"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/event"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/handlers"
|
||||
|
|
@ -64,6 +65,12 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
|
||||
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
|
||||
|
||||
// Apply the locally-configured stale-key window (env/config file) before the
|
||||
// first check-in, so the public-key fetch is governed from boot. The server's
|
||||
// fleet policy overrides it on the next config refresh. crypto clamps to its
|
||||
// doctrinal ceiling regardless of the value here (SEC-028).
|
||||
crypto.SetStaleKeyMaxAge(cfg.CommandSigning.StaleKeyMaxAgeHours)
|
||||
|
||||
// TeeLogger: dual-output logger that emits ETHOS-tagged log lines AND buffers
|
||||
// SystemEvents for the operator dashboard.
|
||||
teeLogger := event.NewTeeLogger(
|
||||
|
|
@ -434,10 +441,17 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
if cfgResp, err := ctx.APIClient.GetConfig(ctx.Cfg.AgentID); err != nil {
|
||||
ctx.TeeLogger.Warning("agent", "config", "config", "config_refresh_failed", map[string]interface{}{"error": err.Error()})
|
||||
} else {
|
||||
if applyServerPolling(ctx.Cfg, cfgResp) {
|
||||
pollingChanged := applyServerPolling(ctx.Cfg, cfgResp)
|
||||
signingChanged := applyServerCommandSigning(ctx.Cfg, cfgResp)
|
||||
if signingChanged {
|
||||
applied := crypto.SetStaleKeyMaxAge(ctx.Cfg.CommandSigning.StaleKeyMaxAgeHours)
|
||||
log.Printf("[INFO] [agent] [config] stale_key_window_updated requested_hours=%d applied=%s",
|
||||
ctx.Cfg.CommandSigning.StaleKeyMaxAgeHours, applied)
|
||||
}
|
||||
if pollingChanged || signingChanged {
|
||||
if saveErr := ctx.Cfg.Save(constants.GetAgentConfigPath()); saveErr != nil {
|
||||
ctx.TeeLogger.Error("agent", "config", "config", fmt.Sprintf("polling_persist_failed error=%v", saveErr), map[string]interface{}{"error": saveErr.Error()})
|
||||
} else {
|
||||
ctx.TeeLogger.Error("agent", "config", "config", fmt.Sprintf("config_persist_failed error=%v", saveErr), map[string]interface{}{"error": saveErr.Error()})
|
||||
} else if pollingChanged {
|
||||
log.Printf("[INFO] [agent] [config] polling_updated jitter=%d backoff_base=%d backoff_max=%d",
|
||||
ctx.Cfg.Polling.JitterMaxSeconds, ctx.Cfg.Polling.BackoffBaseSeconds, ctx.Cfg.Polling.BackoffMaxSeconds)
|
||||
}
|
||||
|
|
@ -872,6 +886,22 @@ func applyServerPolling(cfg *config.Config, resp *client.AgentConfigResponse) bo
|
|||
return changed
|
||||
}
|
||||
|
||||
// applyServerCommandSigning merges fleet-wide command-signing policy delivered
|
||||
// by the server into the local config. Returns true if anything changed so the
|
||||
// caller persists and re-applies it. The agent still clamps the stale-key window
|
||||
// to its doctrinal range at use (crypto.SetStaleKeyMaxAge); this only records
|
||||
// the operator's requested value.
|
||||
func applyServerCommandSigning(cfg *config.Config, resp *client.AgentConfigResponse) bool {
|
||||
if cfg == nil || resp == nil || resp.CommandSigning == nil {
|
||||
return false
|
||||
}
|
||||
if v := resp.CommandSigning.StaleKeyMaxAgeHours; v > 0 && v != cfg.CommandSigning.StaleKeyMaxAgeHours {
|
||||
cfg.CommandSigning.StaleKeyMaxAgeHours = v
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// teeTrackerSaveFailure journals a delivery-tracker persistence failure inward
|
||||
// (ETHOS #1). A tracker that cannot persist risks double-delivery or
|
||||
// replay-rejection after a crash — the server needs the record, not just the
|
||||
|
|
|
|||
|
|
@ -1410,9 +1410,16 @@ func parseOSRelease(data []byte) string {
|
|||
|
||||
// AgentConfigResponse contains configuration delivered by the server.
|
||||
type AgentConfigResponse struct {
|
||||
Subsystems map[string]interface{} `json:"subsystems"`
|
||||
Polling *PollingConfigResponse `json:"polling,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
Subsystems map[string]interface{} `json:"subsystems"`
|
||||
Polling *PollingConfigResponse `json:"polling,omitempty"`
|
||||
CommandSigning *CommandSigningConfigResponse `json:"command_signing,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
// CommandSigningConfigResponse carries fleet-wide command-signing policy from
|
||||
// the server. The agent clamps StaleKeyMaxAgeHours to its doctrinal range.
|
||||
type CommandSigningConfigResponse struct {
|
||||
StaleKeyMaxAgeHours int `json:"stale_key_max_age_hours"`
|
||||
}
|
||||
|
||||
// PollingConfigResponse carries fleet-wide polling resilience tuning from the
|
||||
|
|
|
|||
|
|
@ -83,6 +83,11 @@ type SecurityLogConfig struct {
|
|||
type CommandSigningConfig struct {
|
||||
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_COMMAND_SIGNING_ENABLED" default:"true"`
|
||||
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"` // strict, warning, disabled
|
||||
// StaleKeyMaxAgeHours bounds how long the agent serves its cached server
|
||||
// public key while the server is unreachable (SEC-028). 0 = built-in default.
|
||||
// The agent clamps to a doctrinal ceiling regardless; this only tunes within
|
||||
// it. Delivered fleet-wide via GET /api/v1/agents/:id/config.
|
||||
StaleKeyMaxAgeHours int `json:"stale_key_max_age_hours,omitempty" env:"REDFLAG_AGENT_STALE_KEY_MAX_AGE_HOURS"`
|
||||
}
|
||||
|
||||
// PollingConfig holds admin-adjustable polling resilience tuning. These shape
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
|
|
@ -24,6 +25,53 @@ func InitLogger(l *event.TeeLogger) {
|
|||
|
||||
const defaultCacheTTLHours = 24
|
||||
|
||||
// Stale-cache fallback window (SEC-028). When the server is unreachable the
|
||||
// agent keeps working on its last-known public key, but only for a bounded
|
||||
// time: a key the server rotated OUT must not stay trusted forever just because
|
||||
// the agent can't phone home. The WINDOW LENGTH is operator policy delivered via
|
||||
// security settings (command_signing.stale_key_max_age_hours). The BOUNDS are
|
||||
// doctrine and live here, so neither a setting nor a tampered local config can
|
||||
// widen it past the ceiling or disable it. Forward-only is not a knob; the
|
||||
// existence of a fail-closed ceiling is fixed, only its length is tunable.
|
||||
const (
|
||||
defaultStaleKeyMaxAge = 7 * 24 * time.Hour // policy default; mirrors the server-side setting default
|
||||
minStaleKeyMaxAge = 1 * time.Hour // doctrinal floor — the window is always bounded
|
||||
maxStaleKeyMaxAge = 30 * 24 * time.Hour // doctrinal ceiling — forward-only cap, cannot be exceeded
|
||||
)
|
||||
|
||||
// staleKeyMaxAgeNanos holds the active window. Written by SetStaleKeyMaxAge from
|
||||
// the config-refresh goroutine, read on the command-verify path — atomic so the
|
||||
// two don't race. Zero means "unset": staleKeyMaxAge() falls back to the default.
|
||||
var staleKeyMaxAgeNanos atomic.Int64
|
||||
|
||||
// staleKeyMaxAge returns the active stale-cache window.
|
||||
func staleKeyMaxAge() time.Duration {
|
||||
if n := staleKeyMaxAgeNanos.Load(); n > 0 {
|
||||
return time.Duration(n)
|
||||
}
|
||||
return defaultStaleKeyMaxAge
|
||||
}
|
||||
|
||||
// SetStaleKeyMaxAge applies an operator-configured window (in hours), clamped to
|
||||
// the doctrinal [min,max] range. hours <= 0 means "unset" and restores the
|
||||
// default. The clamp is the enforcement point: a setting or local config that
|
||||
// asks for more than the ceiling gets the ceiling, never the request. Returns
|
||||
// the window actually applied.
|
||||
func SetStaleKeyMaxAge(hours int) time.Duration {
|
||||
d := defaultStaleKeyMaxAge
|
||||
if hours > 0 {
|
||||
d = time.Duration(hours) * time.Hour
|
||||
if d < minStaleKeyMaxAge {
|
||||
d = minStaleKeyMaxAge
|
||||
}
|
||||
if d > maxStaleKeyMaxAge {
|
||||
d = maxStaleKeyMaxAge
|
||||
}
|
||||
}
|
||||
staleKeyMaxAgeNanos.Store(int64(d))
|
||||
return d
|
||||
}
|
||||
|
||||
// getPublicKeyDir returns the platform-specific directory for key cache files
|
||||
// Uses constants package to ensure consistency with other path definitions.
|
||||
func getPublicKeyDir() string {
|
||||
|
|
@ -124,16 +172,35 @@ func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
|
|||
// Fetch primary key from server
|
||||
resp, err := http.Get(serverURL + "/api/v1/public-key")
|
||||
if err != nil {
|
||||
// Network failed — fall back to stale cache if available
|
||||
if cachedKey, loadErr := LoadCachedPublicKey(); loadErr == nil {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "pubkey", "failed to fetch public key (network error), using stale cache", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return cachedKey, nil
|
||||
// Network failed — serve the cached key only within the bounded staleness
|
||||
// window (SEC-028). Past the ceiling, or when the cache age can't be
|
||||
// established, fail closed: forward-only forbids trusting a possibly
|
||||
// rotated-out key indefinitely.
|
||||
cachedKey, loadErr := LoadCachedPublicKey()
|
||||
if loadErr != nil {
|
||||
return nil, fmt.Errorf("failed to fetch public key from server: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch public key from server: %w", err)
|
||||
meta, metaErr := loadCacheMetadata()
|
||||
if metaErr != nil {
|
||||
return nil, fmt.Errorf("public key fetch failed and cache age is unknown (no metadata); refusing stale key: %w", err)
|
||||
}
|
||||
age := time.Since(meta.CachedAt)
|
||||
window := staleKeyMaxAge()
|
||||
if age > window {
|
||||
return nil, fmt.Errorf("public key fetch failed and cached key is stale (age %s > max %s); refusing: %w",
|
||||
age.Round(time.Hour), window, err)
|
||||
}
|
||||
// Degraded trust, not a routine warning: surface at ERROR so an operator
|
||||
// sees an agent running on an un-refreshed signing key.
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "pubkey", "server unreachable, serving stale cached public key within bounded window", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"cache_age": age.Round(time.Minute).String(),
|
||||
"max_stale": window.String(),
|
||||
"key_id": meta.KeyID,
|
||||
})
|
||||
}
|
||||
return cachedKey, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -211,15 +211,40 @@ func (v *CommandVerifier) CheckKeyRotation(keyID string, serverURL string) (ed25
|
|||
}
|
||||
entries, err := FetchAndCacheAllActiveKeys(serverURL)
|
||||
if err != nil {
|
||||
// Fall back to primary cached key
|
||||
// Forward-only (SEC-028): the active-set fetch failed, but trusting the
|
||||
// primary cache here with NO age bound is a wider hole than the primary
|
||||
// fetch path (FetchAndCacheServerPublicKey) closes on its own. Apply the
|
||||
// same bounded-stale ceiling: serve the primary only within the window,
|
||||
// fail closed past it. A primary the server rotated OUT must not verify a
|
||||
// key_id'd command just because /public-keys is unreachable.
|
||||
key, loadErr := LoadCachedPublicKey()
|
||||
if loadErr != nil {
|
||||
return nil, false, fmt.Errorf("key %s not cached and fetch failed: fetch=%v, load=%v", keyID, err, loadErr)
|
||||
return nil, false, fmt.Errorf("key %s not cached and active-set fetch failed: fetch=%v, load=%v", keyID, err, loadErr)
|
||||
}
|
||||
meta, metaErr := loadCacheMetadata()
|
||||
if metaErr != nil {
|
||||
return nil, false, fmt.Errorf("active-set fetch failed and primary cache age unknown (no metadata); refusing: %w", err)
|
||||
}
|
||||
age := time.Since(meta.CachedAt)
|
||||
window := staleKeyMaxAge()
|
||||
if age > window {
|
||||
if teeLogger != nil {
|
||||
teeLogger.Error("agent", "crypto", "verification", "active-set fetch failed and primary cache stale; refusing key_id'd command", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"cache_age": age.Round(time.Hour).String(),
|
||||
"max_stale": window.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return nil, false, fmt.Errorf("active-set fetch failed and primary cache stale (age %s > %s); refusing: %w",
|
||||
age.Round(time.Hour), window, err)
|
||||
}
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "key fetch failed, using primary key", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"error": err.Error(),
|
||||
teeLogger.Warning("agent", "crypto", "verification", "active-set fetch failed, serving bounded-stale primary", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
"cache_age": age.Round(time.Minute).String(),
|
||||
"max_stale": window.String(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return key, false, nil
|
||||
|
|
@ -233,14 +258,16 @@ func (v *CommandVerifier) CheckKeyRotation(keyID string, serverURL string) (ed25
|
|||
}
|
||||
}
|
||||
|
||||
// Requested key not in active set — use primary key and log warning
|
||||
// Requested key_id was fetched but is not in the server's active set. A key
|
||||
// the server has rotated OUT is dead — forward-only doctrine refuses it
|
||||
// rather than silently falling back to the primary key (SEC-028). The caller
|
||||
// surfaces this as a command-verification failure.
|
||||
if teeLogger != nil {
|
||||
teeLogger.Warning("agent", "crypto", "verification", "key not in active set", map[string]interface{}{
|
||||
teeLogger.Error("agent", "crypto", "verification", "command key_id not in server active set, refusing", map[string]interface{}{
|
||||
"key_id": keyID,
|
||||
})
|
||||
}
|
||||
key, err := LoadCachedPublicKey()
|
||||
return key, false, err
|
||||
return nil, false, fmt.Errorf("key %s not in server active set", keyID)
|
||||
}
|
||||
|
||||
// VerifyCommandBatch verifies multiple commands efficiently
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/capability"
|
||||
|
|
@ -235,6 +236,14 @@ type Consumer struct {
|
|||
executor *Executor
|
||||
reporter Reporter // optional
|
||||
downloader ArtifactDownloader // optional, inferred from reporter when available
|
||||
|
||||
// mu serializes ProcessToken. The replay-state guards (helper-side, and the
|
||||
// agent-side desktop-self file until GATE-004 B lands) are check-then-act and
|
||||
// not safe under concurrent processing of the same token. The instance lock is
|
||||
// process-level only; today's single caller (loop.go) never overlaps, but a
|
||||
// future second caller — local-API trigger, retry worker — would race without
|
||||
// this. One token at a time is the design invariant; this enforces it.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewConsumer builds a consumer bound to this host's agent identity.
|
||||
|
|
@ -250,6 +259,9 @@ func NewConsumer(agentID uuid.UUID, executor *Executor, reporter Reporter) *Cons
|
|||
// executor, any token not bound to this host or for an unsupported package type;
|
||||
// the executor enforces the same checks again as the privileged authority.
|
||||
func (c *Consumer) ProcessToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if token.AgentID != c.agentID.String() {
|
||||
log.Printf("[SECURITY] [agent] [supplychain] bind_check_failed token_id=%s token_agent_id=%s host_agent_id=%s",
|
||||
token.TokenID, token.AgentID, c.agentID)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -28,6 +29,114 @@ const osvBatchLimit = 100
|
|||
|
||||
var osvHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// OSV resilience (SEC-029, ETHOS #3 "assume failure; circuit-break fragile
|
||||
// scanners"). A single OSV.dev blip used to force the operator into an override
|
||||
// reason or an abandoned install. We now retry transient transport/5xx/429 with
|
||||
// exponential backoff, and a process-wide breaker fast-fails after a run of
|
||||
// failures so a sustained outage stops hammering the endpoint. The gate stays
|
||||
// fail-CLOSED throughout: an exhausted retry or an open breaker surfaces as
|
||||
// OSVStatusUnreachable, never a silent clear.
|
||||
var osvBackoff = []time.Duration{500 * time.Millisecond, 2 * time.Second, 8 * time.Second}
|
||||
|
||||
const (
|
||||
osvBreakerTrip = 5 // consecutive failed batches before the breaker opens
|
||||
osvBreakerCooldown = 60 * time.Second
|
||||
)
|
||||
|
||||
var osvBreaker struct {
|
||||
mu sync.Mutex
|
||||
consecutive int
|
||||
openUntil time.Time
|
||||
}
|
||||
|
||||
func osvBreakerOpen() bool {
|
||||
osvBreaker.mu.Lock()
|
||||
defer osvBreaker.mu.Unlock()
|
||||
return time.Now().Before(osvBreaker.openUntil)
|
||||
}
|
||||
|
||||
func osvBreakerRecord(success bool) {
|
||||
osvBreaker.mu.Lock()
|
||||
defer osvBreaker.mu.Unlock()
|
||||
if success {
|
||||
osvBreaker.consecutive = 0
|
||||
osvBreaker.openUntil = time.Time{}
|
||||
return
|
||||
}
|
||||
osvBreaker.consecutive++
|
||||
if osvBreaker.consecutive >= osvBreakerTrip {
|
||||
osvBreaker.openUntil = time.Now().Add(osvBreakerCooldown)
|
||||
log.Printf("[WARNING] [agent] [supplychain] osv_breaker_open consecutive=%d cooldown=%s",
|
||||
osvBreaker.consecutive, osvBreakerCooldown)
|
||||
}
|
||||
}
|
||||
|
||||
// osvReadBatch decodes a querybatch response. retryable is true only for
|
||||
// transient server-side conditions (5xx, 429) so the caller backs off; a hard
|
||||
// 4xx or a decode failure is not retryable.
|
||||
func osvReadBatch(resp *http.Response) (batchResp *osvBatchResponse, retryable bool, err error) {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
retryable = resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests
|
||||
return nil, retryable, fmt.Errorf("osv status %d", resp.StatusCode)
|
||||
}
|
||||
var decoded osvBatchResponse
|
||||
if decodeErr := json.NewDecoder(resp.Body).Decode(&decoded); decodeErr != nil {
|
||||
return nil, false, decodeErr
|
||||
}
|
||||
return &decoded, false, nil
|
||||
}
|
||||
|
||||
// osvQueryBatch posts one querybatch payload with retry + breaker, returning the
|
||||
// decoded response or an error the caller surfaces as unreachable.
|
||||
func osvQueryBatch(ctx context.Context, body []byte) (*osvBatchResponse, error) {
|
||||
if osvBreakerOpen() {
|
||||
return nil, fmt.Errorf("osv breaker open")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(osvBackoff); attempt++ {
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(osvBackoff[attempt-1]):
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
"https://api.osv.dev/v1/querybatch", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err // deterministic construction error; not retryable
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := osvHTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Printf("[WARNING] [agent] [supplychain] osv_attempt_failed attempt=%d/%d error=%v",
|
||||
attempt+1, len(osvBackoff)+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
batchResp, retryable, readErr := osvReadBatch(resp)
|
||||
if readErr == nil {
|
||||
osvBreakerRecord(true)
|
||||
return batchResp, nil
|
||||
}
|
||||
lastErr = readErr
|
||||
if !retryable {
|
||||
osvBreakerRecord(false)
|
||||
return nil, readErr
|
||||
}
|
||||
log.Printf("[WARNING] [agent] [supplychain] osv_attempt_failed attempt=%d/%d error=%v",
|
||||
attempt+1, len(osvBackoff)+1, readErr)
|
||||
}
|
||||
|
||||
osvBreakerRecord(false)
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// PkgVersion is the minimal identity OSV needs.
|
||||
type PkgVersion struct {
|
||||
Name string
|
||||
|
|
@ -96,41 +205,17 @@ func CheckClosureOSV(ctx context.Context, pkgType string, pkgs []PkgVersion) (st
|
|||
return OSVStatusUnreachable, 0
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
"https://api.osv.dev/v1/querybatch", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [supplychain] osv_request_build_failed error=%v", err)
|
||||
return OSVStatusUnreachable, 0
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := osvHTTPClient.Do(httpReq)
|
||||
batchResp, err := osvQueryBatch(ctx, body)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [supplychain] osv_unreachable error=%v", err)
|
||||
return OSVStatusUnreachable, 0
|
||||
}
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("osv status %d", resp.StatusCode)
|
||||
return
|
||||
for i, r := range batchResp.Results {
|
||||
if len(r.Vulns) > 0 {
|
||||
vulnCount += len(r.Vulns)
|
||||
log.Printf("[SECURITY] [agent] [supplychain] osv_vulns_found pkg=%s version=%s ecosystem=%s count=%d",
|
||||
batch[i].Name, batch[i].Version, ecosystem, len(r.Vulns))
|
||||
}
|
||||
var batchResp osvBatchResponse
|
||||
if decodeErr := json.NewDecoder(resp.Body).Decode(&batchResp); decodeErr != nil {
|
||||
err = decodeErr
|
||||
return
|
||||
}
|
||||
for i, r := range batchResp.Results {
|
||||
if len(r.Vulns) > 0 {
|
||||
vulnCount += len(r.Vulns)
|
||||
log.Printf("[SECURITY] [agent] [supplychain] osv_vulns_found pkg=%s version=%s ecosystem=%s count=%d",
|
||||
batch[i].Name, batch[i].Version, ecosystem, len(r.Vulns))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [supplychain] osv_unreachable error=%v", err)
|
||||
return OSVStatusUnreachable, 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1892,10 +1892,24 @@ func (h *AgentHandler) GetAgentConfig(c *gin.Context) {
|
|||
polling["backoff_max_seconds"] = h.securitySettings.GetOperationalInt("backoff_max_seconds", 300)
|
||||
}
|
||||
|
||||
// Command-signing policy (fleet-wide). Default matches the agent's built-in
|
||||
// stale-key window so a missing settings row or a server without the settings
|
||||
// service degrades to the same behavior. The agent clamps to its doctrinal
|
||||
// ceiling regardless (SEC-028).
|
||||
commandSigning := gin.H{"stale_key_max_age_hours": 168}
|
||||
if h.securitySettings != nil {
|
||||
if v, err := h.securitySettings.GetSetting("command_signing", "stale_key_max_age_hours"); err == nil {
|
||||
if f, ok := v.(float64); ok {
|
||||
commandSigning["stale_key_max_age_hours"] = int(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"subsystems": config,
|
||||
"polling": polling,
|
||||
"version": time.Now().UTC().Unix(), // Simple version timestamp
|
||||
"subsystems": config,
|
||||
"polling": polling,
|
||||
"command_signing": commandSigning,
|
||||
"version": time.Now().UTC().Unix(), // Simple version timestamp
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -336,6 +336,18 @@ func (s *SecuritySettingsService) ValidateSetting(category, key string, value in
|
|||
return fmt.Errorf("soak_enforcement must be a string")
|
||||
}
|
||||
|
||||
case "command_signing.stale_key_max_age_hours":
|
||||
// Bounded, never zero/infinite: forward-only requires a fail-closed
|
||||
// ceiling. 1h floor, 720h (30d) ceiling — mirrors the agent's doctrinal
|
||||
// clamp so the UI can't offer a value the agent would reject.
|
||||
if hours, ok := value.(float64); ok {
|
||||
if hours < 1 || hours > 720 {
|
||||
return fmt.Errorf("stale_key_max_age_hours must be between 1 and 720 (30 days)")
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("stale_key_max_age_hours must be a number")
|
||||
}
|
||||
|
||||
case "command_signing.algorithm", "update_signing.algorithm":
|
||||
if algo, ok := value.(string); ok {
|
||||
if algo != "ed25519" {
|
||||
|
|
@ -401,6 +413,12 @@ func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]int
|
|||
"enabled": true,
|
||||
"enforcement_mode": "strict",
|
||||
"algorithm": "ed25519",
|
||||
// stale_key_max_age_hours bounds how long an offline agent keeps
|
||||
// trusting its cached server public key before it fails closed
|
||||
// (SEC-028). Operator policy within a doctrinal range — the agent
|
||||
// enforces a hard ceiling regardless, so this tunes but never disables
|
||||
// forward-only. Default 168h (7d).
|
||||
"stale_key_max_age_hours": 168.0,
|
||||
},
|
||||
"update_signing": {
|
||||
"enabled": true,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,15 @@ const SecuritySettings: React.FC = () => {
|
|||
description: 'Cryptographic algorithm for signing commands',
|
||||
disabled: !localSettings?.command_signing?.enabled,
|
||||
},
|
||||
{
|
||||
key: 'stale_key_max_age_hours',
|
||||
label: 'Stale Key Tolerance (hours)',
|
||||
type: 'number',
|
||||
value: localSettings?.command_signing?.stale_key_max_age_hours ?? 168,
|
||||
min: 1,
|
||||
max: 720,
|
||||
description: 'How long an offline agent keeps trusting its cached server key before failing closed. Forward-only doctrine bounds this 1-720h; the agent enforces the ceiling.',
|
||||
},
|
||||
];
|
||||
|
||||
// Update Security Settings
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface CommandSigningSettings {
|
|||
enforcement_mode: 'strict' | 'warning' | 'disabled';
|
||||
algorithm: 'ed25519' | 'rsa' | 'ecdsa';
|
||||
key_id?: string;
|
||||
// How long an offline agent keeps trusting its cached server key before it
|
||||
// fails closed (SEC-028). Bounded 1-720h; the agent enforces the ceiling.
|
||||
stale_key_max_age_hours?: number;
|
||||
}
|
||||
|
||||
export interface UpdateSecuritySettings {
|
||||
|
|
|
|||
Loading…
Reference in a new issue