Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/supplychain/binary_update.go
Fimeg 8b1884eadc fix: nil logger on Windows scan path, rpmEVRAhead equal-version false positive, desktop-self replay token burned before install
- NewOrchestrator initialises a log-only TeeLogger (nil buffer) so the
  Windows service path never carries a nil logger into executeScan
- rpmEVRAhead returns false when all epoch/version/release components
  compare equal; adds test cases for "0:2.0-1" vs "2.0-1" normalisation
- processDesktopSelfToken splits replayCheckAndRecordAgent into
  replayCheckAgent (before install) + recordAgentTokenConsumed (after
  successful install) so a transient install failure does not permanently
  consume the token
- Comments on helperSelfStagingPath (Go) and DEFAULT_HELPER_SELF_SOURCE
  (Rust) name each other as the cross-language counterpart
2026-06-11 02:01:43 -04:00

505 lines
16 KiB
Go

package supplychain
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
const (
AgentSelfPackageType = "agent-self"
HelperSelfPackageType = "helper-self"
DesktopSelfPackageType = "desktop-self"
selfUpdateOperation = "upgrade"
agentSelfStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
// helperSelfStagingPath is a cross-language contract: the Go agent stages the
// new helper binary here, and the Rust helper reads it on self-upgrade.
// The Rust counterpart is DEFAULT_HELPER_SELF_SOURCE in helper/src/main.rs.
// Both must be updated together if this path changes.
helperSelfStagingPath = "/var/lib/redflag/agent/pending-helper.bin"
maxSelfUpdateBinarySize = 500 * 1024 * 1024
)
// Exit codes mirror helper/src/main.rs so capability receipts have one taxonomy
// across helper-executed and agent-local self-update paths.
const (
policyExitOK = 0
policyExitBadToken = 10
policyExitTimeWindow = 12
policyExitKeyNotFound = 14
policyExitSignature = 15
policyExitArtifact = 16
policyExitReplay = 17
policyExitUnsupportedOp = 18
policyExitExecFailed = 19
policyExitInternal = 20
)
func (c *Consumer) processAgentSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if token.Operation != selfUpdateOperation {
return nil, fmt.Errorf("agent-self operation %q not supported", token.Operation)
}
agentEntry, err := findClosureEntry(token, "redflag-agent")
if err != nil {
return nil, err
}
stagedAgent, err := c.stageClosureArtifact(agentEntry, agentSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage agent-self artifact: %w", err)
}
defer os.Remove(stagedAgent)
var helperArgs []string
if helperEntry, err := findClosureEntry(token, "redflag-helper"); err == nil {
stagedHelper, err := c.stageClosureArtifact(helperEntry, helperSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage agent-self helper artifact: %w", err)
}
defer os.Remove(stagedHelper)
helperArgs = []string{"--helper-file", stagedHelper}
}
return c.executor.Execute(ctx, token, helperArgs...)
}
func (c *Consumer) processHelperSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if token.Operation != selfUpdateOperation {
return nil, fmt.Errorf("helper-self operation %q not supported", token.Operation)
}
entry, err := findClosureEntry(token, "redflag-helper")
if err != nil {
return nil, err
}
staged, err := c.stageClosureArtifact(entry, helperSelfStagingPath)
if err != nil {
return nil, fmt.Errorf("stage helper-self artifact: %w", err)
}
defer os.Remove(staged)
return c.executor.Execute(ctx, token)
}
func (c *Consumer) processDesktopSelfToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if err := validateDirectSelfToken(token); err != nil {
return selfPolicyResult(token, "denied", "desktop_self_token_invalid", policyExitSignature, 0, err), nil
}
if token.Operation != selfUpdateOperation {
return selfPolicyResult(
token,
"denied",
"operation_not_allowed",
policyExitUnsupportedOp,
0,
fmt.Errorf("operation=%s (desktop-self requires upgrade)", token.Operation),
), nil
}
// Bound the in-process staging + install to match the executor timeout.
// The agent-self and helper-self paths go through executor.Execute which
// carries its own context timeout; desktop-self runs in-process and needs
// an equivalent guard against hung downloads or stalled I/O.
ctx, cancel := context.WithTimeout(ctx, executorTimeout)
defer cancel()
entry, err := findClosureEntry(token, "redflag-desktop")
if err != nil {
return selfPolicyResult(token, "denied", "desktop_self_no_entry", policyExitBadToken, 0, err), nil
}
// Check context before starting potentially long staging operation.
if err := ctx.Err(); err != nil {
return selfPolicyResult(token, "failed", "desktop_self_cancelled", policyExitExecFailed, 0, err), nil
}
stagingPath := filepath.Join(constants.GetAgentStateDir(), "desktop-self-upgrade.bin")
staged, err := c.stageClosureArtifact(entry, stagingPath)
if err != nil {
return selfPolicyResult(token, "failed", "desktop_self_stage_failed", policyExitArtifact, 0, err), nil
}
defer os.Remove(staged)
// Check replay before any side effects; record consumed only after a
// successful install. The token is Ed25519-signed, hash-pinned, and
// version-pinned, so re-executing the identical install after a transient
// failure (disk full, backup fail) is idempotent — burning the token before
// install would permanently prevent retry (ETHOS #3, #4).
if err := replayCheckAgent(token.TokenID); err != nil {
return selfPolicyResult(token, "denied", "token_already_consumed", policyExitReplay, 1, err), nil
}
target, err := installDesktopBinary(staged)
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] desktop_self_install_failed token_id=%s error=%v", token.TokenID, err)
return selfPolicyResult(token, "failed", "desktop_self_install_failed", policyExitExecFailed, 1, err), nil
}
// Install succeeded — mark token consumed so it cannot be replayed.
if err := recordAgentTokenConsumed(token.TokenID); err != nil {
// Log but do not abort: the binary is already in place. A duplicate
// replay would be caught by hash + version checks, and the helper
// would refuse an identical re-install anyway.
log.Printf("[WARNING] [agent] [supplychain] desktop_self_replay_record_failed token_id=%s error=%v", token.TokenID, err)
}
if err := signalDesktopRestart(target); err != nil {
log.Printf("[WARNING] [agent] [supplychain] desktop_restart_signal_failed token_id=%s error=%v", token.TokenID, err)
}
log.Printf("[SECURITY] [agent] [supplychain] desktop_self_upgraded token_id=%s path=%s", token.TokenID, target)
return selfPolicyResult(token, "executed", "desktop_self_upgraded", policyExitOK, 1, nil), nil
}
func findClosureEntry(token *capability.Token, name string) (*capability.ClosureEntry, error) {
for i := range token.Closure {
if token.Closure[i].Name == name {
return &token.Closure[i], nil
}
}
return nil, fmt.Errorf("closure missing %s entry", name)
}
func (c *Consumer) stageClosureArtifact(entry *capability.ClosureEntry, dstPath string) (string, error) {
if entry.ArtifactPath == "" {
return "", fmt.Errorf("closure entry %s missing artifact_path", entry.Name)
}
if strings.TrimSpace(entry.SHA256) == "" {
return "", fmt.Errorf("closure entry %s missing sha256", entry.Name)
}
dstDir := filepath.Dir(dstPath)
if err := os.MkdirAll(dstDir, 0o700); err != nil {
return "", fmt.Errorf("create staging dir: %w", err)
}
tmp, err := os.CreateTemp(dstDir, filepath.Base(dstPath)+".")
if err != nil {
return "", fmt.Errorf("create staging temp: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("close staging temp: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.Remove(tmpPath)
}
}()
if err := c.fetchArtifactToFile(entry.ArtifactPath, tmpPath); err != nil {
return "", err
}
actual, err := computeFileSHA256Hex(tmpPath)
if err != nil {
return "", err
}
if !strings.EqualFold(actual, entry.SHA256) {
return "", fmt.Errorf("hash mismatch for %s: expected=%s actual=%s", entry.Name, strings.ToLower(entry.SHA256), actual)
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return "", fmt.Errorf("chmod staged artifact: %w", err)
}
if err := os.Rename(tmpPath, dstPath); err != nil {
return "", fmt.Errorf("commit staged artifact: %w", err)
}
cleanup = false
return dstPath, nil
}
func (c *Consumer) fetchArtifactToFile(ref, dstPath string) error {
if info, err := os.Stat(ref); err == nil {
if info.IsDir() {
return fmt.Errorf("artifact path is a directory: %s", ref)
}
return copyRegularFile(ref, dstPath)
} else if !os.IsNotExist(err) {
// Stat failed for a reason other than "not found" (e.g. permission denied).
return fmt.Errorf("stat artifact %s: %w", ref, err)
}
// Local absolute path that doesn't exist — don't fall through to the
// downloader; the file was expected on disk and is missing.
if strings.HasPrefix(ref, "/") {
return fmt.Errorf("artifact not staged at %s", ref)
}
if isDownloadRef(ref) {
if c.downloader == nil {
return fmt.Errorf("artifact downloader unavailable for %s", ref)
}
if _, err := c.downloader.DownloadAuthenticatedToFile(ref, dstPath, maxSelfUpdateBinarySize); err != nil {
return fmt.Errorf("download artifact %s: %w", ref, err)
}
return nil
}
return copyRegularFile(ref, dstPath)
}
func isDownloadRef(ref string) bool {
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://")
}
func validateDirectSelfToken(token *capability.Token) error {
if token.Version != capability.Version {
return fmt.Errorf("unsupported token version=%d supported=%d", token.Version, capability.Version)
}
now := time.Now().UTC().Unix()
if now < token.NotBefore {
return fmt.Errorf("token not yet valid: now=%d not_before=%d", now, token.NotBefore)
}
if now > token.ExpiresAt {
return fmt.Errorf("token expired: now=%d expires_at=%d", now, token.ExpiresAt)
}
pub, err := loadCapabilityPublicKey()
if err != nil {
return err
}
keyID := capability.KeyIDFor(ed25519.PublicKey(pub))
if token.KeyID != keyID {
return fmt.Errorf("key_id mismatch: token=%s local=%s", token.KeyID, keyID)
}
if err := token.Verify(ed25519.PublicKey(pub)); err != nil {
return err
}
return nil
}
func loadCapabilityPublicKey() ([]byte, error) {
keyPath := constants.GetServerPublicKeyPath()
data, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("public key not found at %s: %w", keyPath, err)
}
if len(data) == ed25519.PublicKeySize {
return data, nil
}
trimmed := strings.TrimSpace(string(data))
decoded, decErr := hex.DecodeString(trimmed)
if decErr == nil && len(decoded) == ed25519.PublicKeySize {
return decoded, nil
}
return nil, fmt.Errorf("invalid public key size at %s: raw=%d decoded=%d", keyPath, len(data), len(decoded))
}
// replayCheckAgent returns an error if tokenID has already been consumed.
// It does NOT record the token; call recordAgentTokenConsumed after a
// successful install to close the replay window.
func replayCheckAgent(tokenID string) error {
if _, err := safeTokenFilename(tokenID); err != nil {
return err
}
if strings.ContainsAny(tokenID, "\r\n") {
return fmt.Errorf("unsafe token_id: contains newline")
}
statePath := filepath.Join(constants.GetAgentStateDir(), "consumed-self-tokens")
contents, err := os.ReadFile(statePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read replay state: %w", err)
}
if err == nil {
for _, line := range strings.Split(string(contents), "\n") {
if strings.TrimSpace(line) == tokenID {
return fmt.Errorf("token_id=%s", tokenID)
}
}
}
return nil
}
// recordAgentTokenConsumed appends tokenID to the consumed-self-tokens file.
// Call only after a successful install to avoid permanently burning the token
// on a transient failure.
func recordAgentTokenConsumed(tokenID string) error {
statePath := filepath.Join(constants.GetAgentStateDir(), "consumed-self-tokens")
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
return fmt.Errorf("create replay state dir: %w", err)
}
f, err := os.OpenFile(statePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open replay state: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintln(f, tokenID); err != nil {
return fmt.Errorf("write replay state: %w", err)
}
return nil
}
func installDesktopBinary(staged string) (string, error) {
target, err := desktopBinaryPath()
if err != nil {
return "", err
}
targetDir := filepath.Dir(target)
if info, err := os.Stat(targetDir); err != nil {
return "", fmt.Errorf("desktop install dir unavailable: %w", err)
} else if !info.IsDir() {
return "", fmt.Errorf("desktop install parent is not a directory: %s", targetDir)
}
if info, err := os.Stat(target); err == nil {
if info.IsDir() {
return "", fmt.Errorf("desktop target is a directory: %s", target)
}
// One generation back: overwrite stale .bak with current before replacing.
os.Remove(target + ".bak")
if err := copyRegularFile(target, target+".bak"); err != nil {
return "", fmt.Errorf("backup desktop binary: %w", err)
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("stat desktop binary: %w", err)
}
if err := atomicReplaceFile(staged, target, 0o755); err != nil {
return "", err
}
return target, nil
}
func desktopBinaryPath() (string, error) {
if p := os.Getenv("REDFLAG_DESKTOP_BINARY"); p != "" {
return p, nil
}
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("determine agent executable path: %w", err)
}
name := "redflag-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(filepath.Dir(execPath), name), nil
}
func signalDesktopRestart(target string) error {
if runtime.GOOS != "linux" {
return nil
}
name := filepath.Base(target)
cmd := exec.Command("pkill", "-x", name)
out, err := cmd.CombinedOutput()
if err == nil {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_sent binary=%s", name)
return nil
}
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
log.Printf("[INFO] [agent] [supplychain] desktop_restart_signal_skipped binary=%s reason=not_running", name)
return nil
}
return fmt.Errorf("pkill -x %s failed: %w output=%s", name, err, strings.TrimSpace(string(out)))
}
func atomicReplaceFile(staged, target string, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(target), filepath.Base(target)+".new.")
if err != nil {
return fmt.Errorf("create install temp: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close install temp: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.Remove(tmpPath)
}
}()
if err := copyRegularFile(staged, tmpPath); err != nil {
return err
}
if err := os.Chmod(tmpPath, mode); err != nil {
return fmt.Errorf("chmod install temp: %w", err)
}
if runtime.GOOS == "windows" {
_ = os.Remove(target)
}
if err := os.Rename(tmpPath, target); err != nil {
return fmt.Errorf("replace binary: %w", err)
}
cleanup = false
return nil
}
func copyRegularFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
if info, err := in.Stat(); err != nil {
return fmt.Errorf("stat source: %w", err)
} else if info.IsDir() {
return fmt.Errorf("source is a directory: %s", src)
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return fmt.Errorf("open destination: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return fmt.Errorf("copy file: %w", err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("close destination: %w", err)
}
return nil
}
func computeFileSHA256Hex(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("open for hash: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", fmt.Errorf("hash file: %w", err)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func selfPolicyResult(token *capability.Token, decision, reason string, exitCode, verifiedArtifacts int, resultErr error) *PolicyResult {
var errText string
if resultErr != nil {
errText = resultErr.Error()
}
return &PolicyResult{
TokenID: token.TokenID,
AgentID: token.AgentID,
PackageType: token.PackageType,
Operation: token.Operation,
Decision: decision,
Reason: reason,
Executed: decision == "executed",
VerifiedArtifacts: verifiedArtifacts,
ExitCode: exitCode,
Error: errText,
Timestamp: time.Now().UTC().Unix(),
}
}