Watch
1
0
Fork
You've already forked RedFlag
0

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
This commit is contained in:
Fimeg 2026-06-10 23:34:24 -04:00
commit 8b1884eadc
6 changed files with 63 additions and 8 deletions

View file

@ -10,6 +10,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/gofrs/uuid/v5"
)
// Scanner represents a generic update scanner
@ -74,11 +75,14 @@ type Orchestrator struct {
mu sync.RWMutex
}
// NewOrchestrator creates a new scanner orchestrator
// NewOrchestrator creates a new scanner orchestrator with a log-only TeeLogger
// (nil buffer, no agent ID). Callers that have a TeeLogger should prefer
// NewOrchestratorWithEvents to attach the agent event buffer.
func NewOrchestrator() *Orchestrator {
return &Orchestrator{
scanners: make(map[string]*ScannerConfig),
inventoryScanners: make(map[string]*InventoryScannerConfig),
logger: event.NewTeeLogger(nil, uuid.Nil),
}
}

View file

@ -26,7 +26,12 @@ const (
selfUpdateOperation = "upgrade"
agentSelfStagingPath = "/var/lib/redflag/agent/pending-upgrade.bin"
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
@ -132,15 +137,29 @@ func (c *Consumer) processDesktopSelfToken(ctx context.Context, token *capabilit
}
defer os.Remove(staged)
if err := replayCheckAndRecordAgent(token.TokenID); err != nil {
// 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)
}
@ -284,7 +303,10 @@ func loadCapabilityPublicKey() ([]byte, error) {
return nil, fmt.Errorf("invalid public key size at %s: raw=%d decoded=%d", keyPath, len(data), len(decoded))
}
func replayCheckAndRecordAgent(tokenID string) error {
// 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
}
@ -293,16 +315,25 @@ func replayCheckAndRecordAgent(tokenID string) error {
}
statePath := filepath.Join(constants.GetAgentStateDir(), "consumed-self-tokens")
if contents, err := os.ReadFile(statePath); err == nil {
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)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read replay state: %w", err)
}
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)
}

View file

@ -0,0 +1,4 @@
# Cross-linkers for CI cross-compile matrix (.gitea/workflows/ci.yml).
# Cargo only consults these for the named targets; host builds are unaffected.
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"

View file

@ -65,6 +65,10 @@ const AGENT_SELF_PACKAGE_TYPE: &str = "agent-self";
// then invokes the old helper with a helper-self token. The old helper verifies
// the hash against the token's closure entry and replaces itself.
const HELPER_SELF_PACKAGE_TYPE: &str = "helper-self";
// DEFAULT_HELPER_SELF_SOURCE is a cross-language contract: the Go agent stages the
// new helper binary here before invoking the old helper with a helper-self token.
// The Go counterpart is helperSelfStagingPath in agent/internal/supplychain/binary_update.go.
// Both must be updated together if this path changes.
const DEFAULT_HELPER_SELF_SOURCE: &str = "/var/lib/redflag/agent/pending-helper.bin";
#[derive(Debug, Deserialize, Serialize, Clone)]

View file

@ -154,6 +154,15 @@ func TestGatedTargetAheadUsesPackageManagerVersionShape(t *testing.T) {
if gatedTargetAhead("dnf", "2:99.0.0-1.fc43", "3:1.0.0-1.fc43") {
t.Fatal("dnf lower epoch should not be treated as newer")
}
// Epoch normalisation: "0:2.0-1" and "2.0-1" are semantically identical because
// splitRPMEVR defaults a missing epoch to 0. The string-equality guard is bypassed
// by the textual difference, so the component-wise comparison must return false.
if gatedTargetAhead("dnf", "0:2.0-1", "2.0-1") {
t.Fatal("dnf explicit-epoch-0 vs implicit-epoch-0 should be treated as a no-op")
}
if gatedTargetAhead("dnf", "2.0-1", "0:2.0-1") {
t.Fatal("dnf implicit-epoch-0 vs explicit-epoch-0 should be treated as a no-op")
}
if !gatedTargetAhead("apt", "1.2.3-1ubuntu1~22.04", "1.2.3-1ubuntu1") {
t.Fatal("apt package versions should not be rejected by semver comparison")
}

View file

@ -1016,7 +1016,10 @@ func rpmEVRAhead(target, current string) bool {
if cmp := rpmVersionCompare(targetRelease, currentRelease); cmp != 0 {
return cmp > 0
}
return true
// All components equal — target is not ahead of current.
// This catches textually-different but semantically-equal strings such as
// "0:2.0-1" vs "2.0-1" where splitRPMEVR normalises the missing epoch to 0.
return false
}
func splitRPMEVR(evr string) (epoch int, version string, release string) {