Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-06-14 12:57:04 -04:00
commit 0b1b8124b0
13 changed files with 374 additions and 57 deletions

View file

@ -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.

View file

@ -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 (1720h 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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
}
}

View file

@ -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
})
}

View file

@ -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,

View file

@ -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

View file

@ -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 {