Watch
1
0
Fork
You've already forked RedFlag
0

refactor(agent): scanner orchestrator cleanup + kernel-enforcement wiring + hash-registry follow-ups

Scanner refactor:
- Move Name() onto each scanner; drop scanner_wrappers.go, registry.go,
  scanner_types.go and the duplicate scanner/docker.go (folded into
  orchestrator/docker_scanner.go)
- Add Name() to DNFScanner (was missing — broke orchestrator.Scanner)
- dnf_test.go coverage

Kernel enforcement (Tier 2 scaffold, wired into loop):
- agent/internal/kernel: enforcer, ebpf consumer, windows WDAC stub
- config.KernelEnforcementConfig + defaults/merge, wired in loop.go

Hash registry (Layer 1) follow-ups:
- client GetExpectedHash uses /api/v1/updates/verify-hash
- UpdateHandler takes config; computeAndStorePackageHash uses PublicURL

Server:
- Migration 041: update version_history status constraint
- docker reject path writes "ignored" (matches new constraint)
- queries/filter.go shared filter helper
- updates UI enhancements
This commit is contained in:
Fimeg 2026-05-28 13:31:51 -04:00
commit f0f18d7320
57 changed files with 2096 additions and 1922 deletions

View file

@ -1,4 +1,4 @@
.PHONY: help db-up db-down server agent clean
.PHONY: help db-up db-down server agent clean kernel-enforcer
help: ## Show this help message
@echo 'Usage: make [target]'
@ -41,3 +41,18 @@ build-all: ## Build with go mod tidy for fresh clones
test: ## Run tests
cd server && go test ./...
cd agent && go test ./...
kernel-enforcer: ## Build eBPF kernel enforcer
@echo "Building eBPF kernel enforcer..."
@cd agent && clang -O2 -g -target bpf -mllvm -bpf-stack-size=4096 -c pkg-gate/pkg-gate.c -o pkg-gate/pkg-gate.o \
-I/usr/src/kernels/$(shell uname -r)/vmlinux.h \
-I/usr/src/kernels/$(shell uname -r)/tools/lib/bpf \
-I/usr/src/kernels/$(shell uname -r)/tools/bpf/resolve_btfids/libbpf/include \
-I/usr/src/kernels/$(shell uname -r)/tools/bpf/resolve_btfids/libbpf \
-I/usr/src/kernels/$(shell uname -r)
@echo "eBPF enforcer built successfully"
kernel-enforcer-clean: ## Clean eBPF kernel enforcer artifacts
@echo "Cleaning eBPF kernel enforcer..."
@cd agent && rm -f pkg-gate/pkg-gate.o
@echo "Clean complete"

View file

@ -8,11 +8,12 @@
| Section | Description |
|---------|-------------|
| [[p0-priority-start-here]] | **START HERE** — priority roadmap, current blockers, next steps |
| [[core]] | Core concepts, ETHOS principles, architectural decisions |
| [[components]] | Server, agent, web — component breakdowns |
| [[security]] | Trust boundaries, auth layers, machine binding, key management |
| [[security]] | Trust boundaries, auth layers, machine binding, key management, supply chain gate |
| [[verification]] | Ed25519 signing, verification pipeline, v2/v3 formats, replay protection |
| [[scanners]] | Every scanner (APT, DNF, Winget, WUA, Docker, macOS, Bun, etc) with interaction analysis |
| [[scanners]] | Every scanner (APT, DNF, Winget, WUA, Docker, macOS SoftwareUpdate, etc) with interaction analysis |
| [[flows]] | Data flows — registration, command execution, updates, self-upgrade |
| [[deployment]] | Docker, production checklist, monitoring |
| [[testing]] | Test pyramid, test coverage, manual testing |

View file

@ -5,9 +5,12 @@
Cross-platform agents • Web dashboard • Hardware binding • Ed25519 signing • Supply-chain gates • Full error transparency
```
v0.2.0.2 — May 2026
v0.2.0.5 — May 2026 (pre-release)
```
> **NOT YET OFFICIALLY RELEASED** — Tags do not imply stability. We're in flux.
> Mass enrollment announcement coming soon. Use at your own risk.
RedFlag runs in production homelabs and small operator environments today. It is not a beta with disclaimers; it is a working system that the maintainer also runs as their primary update-management plane. Releases are versioned, migrations are idempotent, and the security model is documented in the code, not in marketing copy.
**What's new in v0.2.0.2:** upstream version-sync subsystem (Repology + endoflife.date adapters, periodic syncer, "Stack Drift" dashboard panel, full CRUD page); aggregated Attention panel surfacing offline agents, failed updates, EOL drift, and recent upstream movement; rate-limiting UI rewritten to the real backend; agent-management token selector that doesn't conflate registration tokens with the install command; idempotent installer that detects existing credentials and skips re-registration; two-axis revocation model locked in; supply-chain package-age gate (Shai-Hulud-class defense). [Full changelog](#-tldr-changelog-dont-trust-the-transport-layer) · [Update instructions](#updating).
@ -102,6 +105,11 @@ curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-to
iwr https://your-server.com/install.ps1 | iex
```
**macOS (curl):**
```bash
curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-token
```
**Manual installation:**
```bash
# Download agent binary
@ -291,9 +299,9 @@ Remove-Item "C:\ProgramData\RedFlag\config.json"
- Windows Winget detection occasionally misses packages (Windows API limitation)
- Some Windows Updates may reappear after installation (known Windows Update quirk)
- Limited mobile dashboard optimization (usable but not ideal)
- No macOS agent support (Linux + Windows only)
- No AUR, Snap, Flatpak, Homebrew scanner support
- Frontend is functional but not polished — some pages need UX love
- macOS agent binaries not yet signed (build paths enabled, awaiting binary)
---
## License

View file

@ -13,6 +13,7 @@ require (
require (
github.com/Microsoft/go-winio v0.4.21 // indirect
github.com/cilium/ebpf v0.21.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect

View file

@ -4,10 +4,13 @@ github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnv
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cilium/ebpf v0.21.0 h1:4dpx1J/B/1apeTmWBH5BkVLayHTkFrMovVPnHEk+l3k=
github.com/cilium/ebpf v0.21.0/go.mod h1:1kHKv6Kvh5a6TePP5vvvoMa1bclRyzUXELSs272fmIQ=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@ -51,6 +54,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f h1:v+bqkkvZj6Oasqi58jzJk03XO0vaXvdb6SS9U1Rbqpw=
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f/go.mod h1:Zt2M6t3i/fnWviIZkuw9wGn2E185P/rWZTqJkIrViGY=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=

View file

@ -1,6 +1,7 @@
package agent
import (
"context"
"fmt"
"log"
"math/rand"
@ -14,6 +15,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/handlers"
"github.com/Fimeg/RedFlag/agent/internal/kernel"
"github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
@ -60,7 +62,7 @@ func RunAgentLoop(cfg *config.Config) error {
wingetScanner := scanner.NewWingetScanner()
storageScanner := orchestrator.NewStorageScanner(version.Version)
systemScanner := orchestrator.NewSystemScanner(version.Version)
dockerScanner, _ := scanner.NewDockerScanner()
dockerScanner, _ := orchestrator.NewDockerScanner()
// Initialize circuit breakers
aptCB := newCircuitBreaker("APT", cfg.Subsystems.APT.CircuitBreaker)
@ -75,13 +77,13 @@ func RunAgentLoop(cfg *config.Config) error {
scanOrchestrator := orchestrator.NewOrchestrator()
// Register all scanners
scanOrchestrator.RegisterScanner("apt", orchestrator.NewAPTScannerWrapper(aptScanner), aptCB, cfg.Subsystems.APT.Timeout, cfg.Subsystems.APT.Enabled)
scanOrchestrator.RegisterScanner("dnf", orchestrator.NewDNFScannerWrapper(dnfScanner), dnfCB, cfg.Subsystems.DNF.Timeout, cfg.Subsystems.DNF.Enabled)
scanOrchestrator.RegisterScanner("windows", orchestrator.NewWindowsUpdateScannerWrapper(windowsUpdateScanner), windowsCB, cfg.Subsystems.Windows.Timeout, cfg.Subsystems.Windows.Enabled)
scanOrchestrator.RegisterScanner("winget", orchestrator.NewWingetScannerWrapper(wingetScanner), wingetCB, cfg.Subsystems.Winget.Timeout, cfg.Subsystems.Winget.Enabled)
scanOrchestrator.RegisterScanner("storage", orchestrator.NewStorageScannerWrapper(storageScanner), storageCB, cfg.Subsystems.Storage.Timeout, cfg.Subsystems.Storage.Enabled)
scanOrchestrator.RegisterScanner("system", orchestrator.NewSystemScannerWrapper(systemScanner), systemCB, cfg.Subsystems.System.Timeout, cfg.Subsystems.System.Enabled)
scanOrchestrator.RegisterScanner("docker", orchestrator.NewDockerScannerWrapper(dockerScanner), dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
scanOrchestrator.RegisterScanner("apt", aptScanner, aptCB, cfg.Subsystems.APT.Timeout, cfg.Subsystems.APT.Enabled)
scanOrchestrator.RegisterScanner("dnf", dnfScanner, dnfCB, cfg.Subsystems.DNF.Timeout, cfg.Subsystems.DNF.Enabled)
scanOrchestrator.RegisterScanner("windows", windowsUpdateScanner, windowsCB, cfg.Subsystems.Windows.Timeout, cfg.Subsystems.Windows.Enabled)
scanOrchestrator.RegisterScanner("winget", wingetScanner, wingetCB, cfg.Subsystems.Winget.Timeout, cfg.Subsystems.Winget.Enabled)
scanOrchestrator.RegisterScanner("storage", storageScanner, storageCB, cfg.Subsystems.Storage.Timeout, cfg.Subsystems.Storage.Enabled)
scanOrchestrator.RegisterScanner("system", systemScanner, systemCB, cfg.Subsystems.System.Timeout, cfg.Subsystems.System.Enabled)
scanOrchestrator.RegisterScanner("docker", dockerScanner, dockerCB, cfg.Subsystems.Docker.Timeout, cfg.Subsystems.Docker.Enabled)
// Initialize acknowledgment tracker (result acks — pending_acks.json)
ackTracker := acknowledgment.NewTracker(constants.GetAgentStateDir())
@ -89,6 +91,14 @@ func RunAgentLoop(cfg *config.Config) error {
log.Printf("[WARNING] [agent] [acknowledgment] load_pending_acks_failed error=%v", err)
}
// Initialize kernel enforcement enforcer
kernelEnforcer, err := kernel.NewEnforcer(cfg)
if err != nil {
log.Printf("[WARNING] [agent] [kernel] enforcer_init_failed error=%v", err)
} else {
log.Printf("[INFO] [agent] [kernel] %s_enforcer_started", kernelEnforcer.GetPackageType())
}
// Initialize receipt tracker (command-receipt confirmation — pending_receipts.json)
// Migration 033 §2: doctrine TODO-full-command-lifecycle.md
receiptTracker := receipt.NewTracker(constants.GetAgentStateDir())
@ -96,6 +106,14 @@ func RunAgentLoop(cfg *config.Config) error {
log.Printf("[WARNING] [agent] [receipt] load_pending_receipts_failed error=%v", err)
}
// Initialize confirmed tracker (command-completion confirmation from server)
// This tracks commands the server has acknowledged via ReportLog, preventing
// false-positive duplicate rejections when server hasn't processed the log yet.
confirmedTracker := orchestrator.NewConfirmedTracker(constants.GetAgentStateDir())
if err := confirmedTracker.Load(); err != nil {
log.Printf("[WARNING] [agent] [confirmed] load_confirmed_completed_failed error=%v", err)
}
// Initialize command handler
securityLogger, _ := logging.NewSecurityLogger(cfg, constants.GetAgentStateDir())
commandHandler, err := orchestrator.NewCommandHandler(cfg, constants.GetAgentStateDir(), securityLogger, log.New(os.Stdout, "", log.LstdFlags))
@ -109,6 +127,7 @@ func RunAgentLoop(cfg *config.Config) error {
APIClient: apiClient,
AckTracker: ackTracker,
ReceiptTracker: receiptTracker,
ConfirmedTracker: confirmedTracker,
CommandHandler: commandHandler,
ScanOrchestrator: scanOrchestrator,
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
@ -130,19 +149,36 @@ type LoopContext struct {
APIClient *client.Client
AckTracker *acknowledgment.Tracker
ReceiptTracker *receipt.Tracker
ConfirmedTracker *orchestrator.ConfirmedTracker // tracks commands server confirmed as completed
CommandHandler *orchestrator.CommandHandler
ScanOrchestrator *orchestrator.Orchestrator
CircuitBreakers map[string]*circuitbreaker.CircuitBreaker
KernelEnforcer kernel.Enforcer
Ctx context.Context
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
}
// RunPollingLoop runs the main agent polling loop.
// It is called by RunAgentLoop and may also be called by the Windows service
// with a stop channel. When stopCh is non-nil, the loop selects on it and exits cleanly.
func RunPollingLoop(ctx *LoopContext) error {
func RunPollingLoop(loopCtx *LoopContext) error {
// Panic recovery for the polling loop [TD-002]
defer recovery.Recover("agent_polling_loop")
ctx := loopCtx
// Start kernel enforcement enforcer
if ctx.KernelEnforcer != nil {
if err := ctx.KernelEnforcer.Start(ctx.Ctx); err != nil {
log.Printf("[ERROR] [agent] [kernel] enforcer_start_failed error=%v", err)
}
defer func() {
if err := ctx.KernelEnforcer.Stop(); err != nil {
log.Printf("[ERROR] [agent] [kernel] enforcer_stop_failed error=%v", err)
}
}()
}
consecutiveFailures := 0
lastSystemInfoUpdate := time.Time{}
postUpdateCleanupDone := false
@ -273,6 +309,22 @@ func RunPollingLoop(ctx *LoopContext) error {
}
}
// Drop confirmed completions the server acknowledged via ReportLog.
// This is the key fix for duplicate command rejections: if the server
// has confirmed a command as completed, the agent should NOT reject it
// as a duplicate even if it's in the executed set. The race was:
// 1. Server sends command -> agent executes -> agent reports log
// 2. Server hasn't processed log yet -> sends command again on next poll
// 3. Agent rejects as duplicate (BUG)
// With this fix: agent checks if server confirmed -> if yes, allow.
if response != nil && len(response.ConfirmedCommandIDs) > 0 {
ctx.ConfirmedTracker.Confirm(response.ConfirmedCommandIDs)
log.Printf("[INFO] [agent] [confirmed] completions_confirmed count=%d", len(response.ConfirmedCommandIDs))
if err := ctx.ConfirmedTracker.Save(); err != nil {
log.Printf("[ERROR] [agent] [confirmed] save_failed error=%v", err)
}
}
// Report circuit breaker health
go reportCircuitBreakerHealth(ctx)

View file

@ -464,6 +464,7 @@ type CommandsResponse struct {
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded (drop from pending_acks)
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server flipped sent→received (drop from outbound receipts)
ConfirmedCommandIDs []string `json:"confirmed_command_ids,omitempty"` // Command IDs server confirmed as completed (via ReportLog)
}
// RapidPollingConfig contains rapid polling configuration from server
@ -1128,7 +1129,7 @@ func (c *Client) GetActivePublicKeys(serverURL string) ([]ActivePublicKeyEntry,
// GetExpectedHash retrieves the expected SHA256 hash for a package (Layer 1: Hash Registry)
func (c *Client) GetExpectedHash(packageType, packageName string, agentID uuid.UUID) (string, error) {
url := fmt.Sprintf("%s/dashboard/updates/verify-hash?package_type=%s&package_name=%s&agent_id=%s",
url := fmt.Sprintf("%s/api/v1/updates/verify-hash?package_type=%s&package_name=%s&agent_id=%s",
c.baseURL, packageType, packageName, agentID.String())
resp, err := c.http.Get(url)
@ -1157,3 +1158,11 @@ func (c *Client) GetExpectedHash(packageType, packageName string, agentID uuid.U
return result.ExpectedSHA256, nil
}
// GetWdacPolicyHash returns the WDAC policy hash (stub for Windows WDAC enforcement)
// This is a placeholder - actual WDAC policy fetching requires Windows-specific COM APIs
func (c *Client) GetWdacPolicyHash(agentID uuid.UUID, serverURL string) (string, error) {
// WDAC not implemented yet - return empty hash
// This causes fail-closed behavior in WDAC enforcer
return "", fmt.Errorf("wdac_policy_not_implemented")
}

View file

@ -129,9 +129,18 @@ type Config struct {
DisplayName string `json:"display_name,omitempty"` // Human-readable name
Organization string `json:"organization,omitempty"` // Organization/group
// OS Type (linux, windows, darwin)
OSType string `json:"os_type,omitempty"`
// OS holds OS-specific information
OS OS `json:"os,omitempty"`
// Subsystem Configuration
Subsystems SubsystemsConfig `json:"subsystems,omitempty"` // Scanner subsystem configs
// Kernel Enforcement Configuration
KernelEnforcement KernelEnforcementConfig `json:"kernel_enforcement,omitempty"`
// Migration State
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
}
@ -586,6 +595,31 @@ func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
// OSType represents the operating system type
type OSType string
const (
OSTypeLinux OSType = "linux"
OSTypeWindows OSType = "windows"
OSTypeDarwin OSType = "darwin"
)
// OS holds OS-specific information
type OS struct {
Type OSType `json:"type"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`
}
// GetOSType returns the OS type from the config
func (c *Config) GetOSType() OSType {
if c.OS.Type != "" {
return c.OS.Type
}
// Default to linux if not set
return OSTypeLinux
}
// NeedsRegistration checks if the agent needs to register with a token
func (c *Config) NeedsRegistration() bool {
return c.RegistrationToken != "" && c.AgentID == uuid.Nil

View file

@ -0,0 +1,55 @@
package config
import (
"time"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// KernelEnforcementConfig holds configuration for kernel-level enforcement
type KernelEnforcementConfig struct {
// Enable kernel enforcement (eBPF on Linux, WDAC on Windows)
Enabled bool `json:"enabled" env:"REDFLAG_KERNEL_ENFORCEMENT_ENABLED" default:"true"`
// Ring buffer path for eBPF events (Linux only)
RingBufferPath string `json:"ring_buffer_path" env:"REDFLAG_RING_BUFFER_PATH" default:"/var/run/redflag/ebpf-ring"`
// Socket path for rs-helper communication
RsHelperSocket string `json:"rs_helper_socket" env:"REDFLAG_RS_HELPER_SOCKET" default:"/var/run/redflag/rs-helper.sock"`
// Policy check timeout
PolicyCheckTimeout time.Duration `json:"policy_check_timeout" env:"REDFLAG_POLICY_CHECK_TIMEOUT" default:"10s"`
// Fail-closed mode (deny on kernel enforcement failure)
FailClosed bool `json:"fail_closed" env:"REDFLAG_KERNEL_ENFORCEMENT_FAIL_CLOSED" default:"true"`
}
// GetDefaultKernelEnforcementConfig returns default kernel enforcement configuration
func GetDefaultKernelEnforcementConfig() KernelEnforcementConfig {
return KernelEnforcementConfig{
Enabled: true,
RingBufferPath: constants.GetAgentStateDir() + "/ebpf-ring",
RsHelperSocket: "/var/run/redflag/rs-helper.sock",
PolicyCheckTimeout: 10 * time.Second,
FailClosed: true,
}
}
// MergeKernelEnforcement merges kernel enforcement config from source into target
func MergeKernelEnforcement(target, source KernelEnforcementConfig) {
if source.Enabled {
target.Enabled = source.Enabled
}
if source.RingBufferPath != "" {
target.RingBufferPath = source.RingBufferPath
}
if source.RsHelperSocket != "" {
target.RsHelperSocket = source.RsHelperSocket
}
if source.PolicyCheckTimeout > 0 {
target.PolicyCheckTimeout = source.PolicyCheckTimeout
}
if source.FailClosed {
target.FailClosed = source.FailClosed
}
}

View file

@ -13,6 +13,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/display"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
@ -43,11 +44,11 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
// Initialize scanners
aptScanner := scanner.NewAPTScanner()
dnfScanner := scanner.NewDNFScanner()
dockerScanner, _ := scanner.NewDockerScanner()
dockerScanner, _ := orchestrator.NewDockerScanner()
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
wingetScanner := scanner.NewWingetScanner()
fmt.Println("🔍 Scanning for updates...")
fmt.Println("Scanning for updates...")
var allUpdates []client.UpdateReportItem
// Scan APT updates
@ -55,7 +56,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
fmt.Println(" - Scanning APT packages...")
updates, err := aptScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ APT scan failed: %v\n", err)
fmt.Printf(" APT scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d APT updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
@ -67,7 +68,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
fmt.Println(" - Scanning DNF packages...")
updates, err := dnfScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ DNF scan failed: %v\n", err)
fmt.Printf(" DNF scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d DNF updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
@ -79,7 +80,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
fmt.Println(" - Scanning Docker images...")
updates, err := dockerScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ Docker scan failed: %v\n", err)
fmt.Printf(" Docker scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d Docker image updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
@ -91,7 +92,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
fmt.Println(" - Scanning Windows updates...")
updates, err := windowsUpdateScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ Windows Update scan failed: %v\n", err)
fmt.Printf(" Windows Update scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d Windows updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
@ -103,7 +104,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
fmt.Println(" - Scanning Winget packages...")
updates, err := wingetScanner.Scan()
if err != nil {
fmt.Printf(" ⚠️ Winget scan failed: %v\n", err)
fmt.Printf(" Winget scan failed: %v\n", err)
} else {
fmt.Printf(" ✓ Found %d Winget package updates\n", len(updates))
allUpdates = append(allUpdates, updates...)
@ -113,7 +114,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
// Load and update cache
localCache, err := cache.Load()
if err != nil {
fmt.Printf("⚠️ Warning: Failed to load cache: %v\n", err)
fmt.Printf("Warning: Failed to load cache: %v\n", err)
localCache = &cache.LocalCache{}
}
@ -126,7 +127,7 @@ func ScanCommand(cfg *config.Config, exportFormat string) error {
// Save cache
if err := localCache.Save(); err != nil {
fmt.Printf("⚠️ Warning: Failed to save cache: %v\n", err)
fmt.Printf("Warning: Failed to save cache: %v\n", err)
}
// Display results
@ -141,31 +142,31 @@ func StatusCommand(cfg *config.Config) error {
fmt.Println("==================================================================")
// Agent information
fmt.Printf("📋 Agent ID: %s\n", cfg.AgentID)
fmt.Printf("🌐 Server: %s\n", cfg.ServerURL)
fmt.Printf("⏱️ Check-in Interval: %ds\n", cfg.CheckInInterval)
fmt.Printf("🔧 Version: %s\n", version.Version)
fmt.Printf("Agent ID: %s\n", cfg.AgentID)
fmt.Printf("Server: %s\n", cfg.ServerURL)
fmt.Printf("Check-in Interval: %ds\n", cfg.CheckInInterval)
fmt.Printf("Version: %s\n", version.Version)
// Registration status
if cfg.IsRegistered() {
fmt.Println("Registration: Registered")
fmt.Println("Registration: Registered")
} else {
fmt.Println("Registration: Not registered")
fmt.Println("Registration: Not registered")
}
// System information
sysInfo, err := system.GetSystemInfo(version.Version)
if err == nil {
fmt.Printf("🖥️ Hostname: %s\n", sysInfo.Hostname)
fmt.Printf("💻 OS: %s %s (%s)\n", sysInfo.OSType, sysInfo.OSVersion, sysInfo.OSArchitecture)
fmt.Printf("⏱️ Uptime: %s\n", sysInfo.Uptime)
fmt.Printf("Hostname: %s\n", sysInfo.Hostname)
fmt.Printf("OS: %s %s (%s)\n", sysInfo.OSType, sysInfo.OSVersion, sysInfo.OSArchitecture)
fmt.Printf("Uptime: %s\n", sysInfo.Uptime)
}
// Cache status
localCache, err := cache.Load()
if err == nil && !localCache.LastScanTime.IsZero() {
fmt.Printf("🔍 Last Scan: %s\n", localCache.LastScanTime.Format(time.RFC3339))
fmt.Printf("📦 Updates Available: %d\n", len(localCache.Updates))
fmt.Printf("Last Scan: %s\n", localCache.LastScanTime.Format(time.RFC3339))
fmt.Printf("Updates Available: %d\n", len(localCache.Updates))
}
fmt.Println("==================================================================")

View file

@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"log"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
@ -9,6 +10,25 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
)
// reportFailure sends a command failure back to the server so it doesn't
// get stuck in "received" indefinitely. Called when a handler that already
// reported "started" hits an error before completing.
func reportFailure(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, cmdType, cmdID, errMsg string) {
logReport := client.LogReport{
CommandID: cmdID,
Action: cmdType,
Result: "failed",
Stderr: errMsg,
ExitCode: 1,
DurationSeconds: 0,
}
// Best-effort: don't block the caller if the server is unreachable.
if rErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); rErr != nil {
log.Printf("[ERROR] [agent] [dispatch] failure_report_failed cmd_type=%s cmd_id=%s error=%q", cmdType, cmdID, rErr)
}
}
// DispatchCrossPlatformCommand handles command types that work on every
// platform (scans, agent self-update). Returns true if cmd.Type matched a
// known cross-platform command; the caller is then responsible only for
@ -59,6 +79,7 @@ func DispatchCrossPlatformCommand(
}
if err != nil {
log.Printf("[ERROR] [agent] [%s] command_failed error=%q", cmd.Type, err)
reportFailure(apiClient, cfg, ackTracker, cmd.Type, cmd.ID, fmt.Sprintf("command failed: %s", err))
}
return true
}

View file

@ -16,6 +16,12 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/scanner"
)
// mapAny extracts a typed value from map[string]interface{} with zero fallback.
func mapAny[T any](v interface{}) T {
r, _ := v.(T)
return r
}
// reportLogWithAck reports a command log to the server and tracks it for acknowledgment
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
// Track this command result as pending acknowledgment
@ -53,7 +59,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
// Linux: Try APT first, then DNF
if osType == "linux" {
// Try APT
aptScanner := orchestrator.NewAPTScannerWrapper(scanner.NewAPTScanner())
aptScanner := scanner.NewAPTScanner()
if aptScanner.IsAvailable() {
log.Println("[updates] Running APT scan...")
result, err := orch.ScanSingle(ctx, "apt")
@ -77,7 +83,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
}
// Try DNF
dnfScanner := orchestrator.NewDNFScannerWrapper(scanner.NewDNFScanner())
dnfScanner := scanner.NewDNFScanner()
if dnfScanner.IsAvailable() {
log.Println("[updates] Running DNF scan...")
result, err := orch.ScanSingle(ctx, "dnf")
@ -104,7 +110,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
// Windows: Try Windows Update and Winget
if osType == "windows" {
// Try Windows Update
windowsScanner := orchestrator.NewWindowsUpdateScannerWrapper(scanner.NewWindowsUpdateScanner())
windowsScanner := scanner.NewWindowsUpdateScanner()
if windowsScanner.IsAvailable() {
log.Println("[updates] Running Windows Update scan...")
result, err := orch.ScanSingle(ctx, "windows")
@ -128,7 +134,7 @@ func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker
}
// Try Winget
wingetScanner := orchestrator.NewWingetScannerWrapper(scanner.NewWingetScanner())
wingetScanner := scanner.NewWingetScanner()
if wingetScanner.IsAvailable() {
log.Println("[updates] Running Winget scan...")
result, err := orch.ScanSingle(ctx, "winget")
@ -197,13 +203,11 @@ func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker
ctx := context.Background()
startTime := time.Now()
// Execute storage scanner
result, err := orch.ScanSingle(ctx, "storage")
if err != nil {
return fmt.Errorf("failed to scan storage: %w", err)
}
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
@ -211,53 +215,39 @@ func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker
stdout += fmt.Sprintf("\nStorage scan completed in %.2f seconds\n", duration.Seconds())
// Report storage metrics to server using dedicated endpoint
// Use proper StorageMetricReport with clean field names
storageScanner := orchestrator.NewStorageScanner(cfg.AgentVersion)
var metrics []orchestrator.StorageMetric // Declare outside if block for ReportLog access
if storageScanner.IsAvailable() {
var err error
metrics, err = storageScanner.ScanStorage()
if err != nil {
return fmt.Errorf("failed to scan storage metrics: %w", err)
if len(result.Updates) > 0 {
metricItems := make([]models.StorageMetric, 0, len(result.Updates))
for _, u := range result.Updates {
m := u.Metadata
metricItems = append(metricItems, models.StorageMetric{
Mountpoint: u.PackageName,
Device: u.RepositorySource,
DiskType: mapAny[string](m["disk_type"]),
Filesystem: mapAny[string](m["filesystem"]),
TotalBytes: mapAny[int64](m["total_bytes"]),
UsedBytes: mapAny[int64](m["used_bytes"]),
AvailableBytes: mapAny[int64](m["available_bytes"]),
UsedPercent: mapAny[float64](m["used_percent"]),
IsRoot: mapAny[bool](m["is_root"]),
IsLargest: mapAny[bool](m["is_largest"]),
Severity: u.Severity,
})
}
if len(metrics) > 0 {
// Convert from orchestrator.StorageMetric to models.StorageMetric
metricItems := make([]models.StorageMetric, 0, len(metrics))
for _, m := range metrics {
item := models.StorageMetric{
Mountpoint: m.Mountpoint,
Device: m.Device,
DiskType: m.DiskType,
Filesystem: m.Filesystem,
TotalBytes: m.TotalBytes,
UsedBytes: m.UsedBytes,
AvailableBytes: m.AvailableBytes,
UsedPercent: m.UsedPercent,
IsRoot: m.IsRoot,
IsLargest: m.IsLargest,
Severity: m.Severity,
Metadata: m.Metadata,
}
metricItems = append(metricItems, item)
}
report := models.StorageMetricReport{
AgentID: cfg.AgentID,
CommandID: commandID,
Timestamp: time.Now().UTC(),
Metrics: metricItems,
}
if err := apiClient.ReportStorageMetrics(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report storage metrics: %w", err)
}
log.Printf("[INFO] [storage] Successfully reported %d storage metrics to server\n", len(metrics))
report := models.StorageMetricReport{
AgentID: cfg.AgentID,
CommandID: commandID,
Timestamp: time.Now().UTC(),
Metrics: metricItems,
}
if err := apiClient.ReportStorageMetrics(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report storage metrics: %w", err)
}
log.Printf("[INFO] [storage] Successfully reported %d storage metrics to server\n", len(result.Updates))
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_storage",
@ -269,7 +259,7 @@ func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker
Metadata: map[string]string{
"subsystem_label": "Disk Usage",
"subsystem": "storage",
"metrics_count": fmt.Sprintf("%d", len(metrics)),
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
@ -290,13 +280,11 @@ func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *
ctx := context.Background()
startTime := time.Now()
// Execute system scanner
result, err := orch.ScanSingle(ctx, "system")
if err != nil {
return fmt.Errorf("failed to scan system: %w", err)
}
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
@ -304,47 +292,33 @@ func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *
stdout += fmt.Sprintf("\nSystem scan completed in %.2f seconds\n", duration.Seconds())
// Report system metrics to server using dedicated endpoint
// Get system scanner and use proper interface
systemScanner := orchestrator.NewSystemScanner(cfg.AgentVersion)
var metrics []orchestrator.SystemMetric // Declare outside if block for ReportLog access
if systemScanner.IsAvailable() {
var err error
metrics, err = systemScanner.ScanSystem()
if err != nil {
return fmt.Errorf("failed to scan system metrics: %w", err)
if len(result.Updates) > 0 {
metricItems := make([]client.MetricsReportItem, 0, len(result.Updates))
for _, u := range result.Updates {
metricItems = append(metricItems, client.MetricsReportItem{
PackageType: u.PackageType,
PackageName: u.PackageName,
CurrentVersion: u.CurrentVersion,
AvailableVersion: u.AvailableVersion,
Severity: u.Severity,
RepositorySource: u.RepositorySource,
Metadata: u.Metadata,
})
}
if len(metrics) > 0 {
// Convert SystemMetric to MetricsReportItem for API call
metricItems := make([]client.MetricsReportItem, 0, len(metrics))
for _, metric := range metrics {
item := client.MetricsReportItem{
PackageType: "system",
PackageName: metric.MetricName,
CurrentVersion: metric.CurrentValue,
AvailableVersion: metric.AvailableValue,
Severity: metric.Severity,
RepositorySource: metric.MetricType,
Metadata: metric.Metadata,
}
metricItems = append(metricItems, item)
}
report := client.MetricsReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Metrics: metricItems,
}
if err := apiClient.ReportMetrics(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report system metrics: %w", err)
}
log.Printf("[INFO] [agent] [system] Reported %d system metrics to server\n", len(metrics))
report := client.MetricsReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Metrics: metricItems,
}
if err := apiClient.ReportMetrics(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report system metrics: %w", err)
}
log.Printf("[INFO] [agent] [system] Reported %d system metrics to server\n", len(result.Updates))
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_system",
@ -356,7 +330,7 @@ func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *
Metadata: map[string]string{
"subsystem_label": "System Metrics",
"subsystem": "system",
"metrics_count": fmt.Sprintf("%d", len(metrics)),
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
@ -377,13 +351,11 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
ctx := context.Background()
startTime := time.Now()
// Execute Docker scanner
result, err := orch.ScanSingle(ctx, "docker")
if err != nil {
return fmt.Errorf("failed to scan Docker: %w", err)
}
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
@ -391,63 +363,43 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
stdout += fmt.Sprintf("\nDocker scan completed in %.2f seconds\n", duration.Seconds())
// Report Docker images to server using dedicated endpoint
// Get Docker scanner and use proper interface
dockerScanner, err := orchestrator.NewDockerScanner()
if err != nil {
return fmt.Errorf("failed to create Docker scanner: %w", err)
}
defer dockerScanner.Close()
var images []orchestrator.DockerImage // Declare outside if block for ReportLog access
var updateCount int // Declare outside if block for ReportLog access
if dockerScanner.IsAvailable() {
images, err = dockerScanner.ScanDocker()
if err != nil {
return fmt.Errorf("failed to scan Docker images: %w", err)
updateCount := 0
if len(result.Updates) > 0 {
imageItems := make([]client.DockerReportItem, 0, len(result.Updates))
for _, u := range result.Updates {
hasUpdate := false
if v, ok := u.Metadata["has_update"]; ok {
hasUpdate, _ = v.(bool)
}
if hasUpdate {
updateCount++
}
imageItems = append(imageItems, client.DockerReportItem{
PackageType: u.PackageType,
PackageName: u.PackageName,
CurrentVersion: u.CurrentVersion,
AvailableVersion: u.AvailableVersion,
Severity: u.Severity,
RepositorySource: u.RepositorySource,
Metadata: u.Metadata,
})
}
// Always report all Docker images (not just those with updates)
updateCount = 0 // Reset for counting
if len(images) > 0 {
// Convert DockerImage to DockerReportItem for API call
imageItems := make([]client.DockerReportItem, 0, len(images))
for _, image := range images {
item := client.DockerReportItem{
PackageType: "docker_image",
PackageName: image.ImageName,
CurrentVersion: image.ImageID,
AvailableVersion: image.LatestImageID,
Severity: image.Severity,
RepositorySource: image.RepositorySource,
Metadata: image.Metadata,
}
imageItems = append(imageItems, item)
}
report := client.DockerReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Images: imageItems,
}
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Docker images: %w", err)
}
for _, image := range images {
if image.HasUpdate {
updateCount++
}
}
log.Printf("[INFO] [agent] [docker] Reported %d Docker images (%d with updates) to server\n", len(images), updateCount)
} else {
log.Println("[INFO] [agent] [docker] No Docker images found")
report := client.DockerReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Images: imageItems,
}
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Docker images: %w", err)
}
log.Printf("[INFO] [agent] [docker] Reported %d Docker images (%d with updates) to server\n", len(result.Updates), updateCount)
} else {
log.Println("[INFO] [agent] [docker] Docker not available on this system")
log.Println("[INFO] [agent] [docker] No Docker images found")
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_docker",
@ -459,7 +411,7 @@ func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *
Metadata: map[string]string{
"subsystem_label": "Docker Images",
"subsystem": "docker",
"images_count": fmt.Sprintf("%d", len(images)),
"images_count": fmt.Sprintf("%d", len(result.Updates)),
"updates_found": fmt.Sprintf("%d", updateCount),
},
}

View file

@ -157,7 +157,7 @@ ProtectHome=true
# self-upgrade can write the new binary and .bak alongside it
# ProtectSystem=strict makes the mount RO inside the unit's namespace
# and sudo inherits that namespace.
ReadWritePaths=/var/lib/redflag-agent /usr/local/bin
ReadWritePaths=/var/lib/redflag-agent /usr/local/bin /var/log /var/cache
PrivateTmp=true
[Install]

View file

@ -0,0 +1,298 @@
package kernel
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"unsafe"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/rlimit"
"github.com/cilium/ebpf/ringbuf"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
const (
// MAGIC_NUMBER matches the eBPF program
MAGIC_NUMBER = 0xDEADBEEF
)
// EBPFConsumer reads events from the eBPF ring buffer and forwards them to the policy checker
type EBPFConsumer struct {
config *config.Config
running bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
wgLock sync.Mutex
rd *ringbuf.Reader
elfPath string
}
// NewEBPFConsumer creates a new eBPF ring buffer consumer
func NewEBPFConsumer(cfg *config.Config) (*EBPFConsumer, error) {
ctx, cancel := context.WithCancel(context.Background())
return &EBPFConsumer{
config: cfg,
ctx: ctx,
cancel: cancel,
elfPath: cfg.KernelEnforcement.RingBufferPath + "/pkg-gate.o",
}, nil
}
// Start begins consuming events from the eBPF ring buffer
func (e *EBPFConsumer) Start() error {
e.wgLock.Lock()
if e.running {
e.wgLock.Unlock()
return nil
}
e.running = true
e.wgLock.Unlock()
log.Printf("[INFO] [kernel] [ebpf] consumer_started")
// Remove memlock limit for eBPF
if err := rlimit.RemoveMemlock(); err != nil {
return fmt.Errorf("failed to remove memlock: %w", err)
}
// Load the eBPF collection from the ELF file
spec, err := ebpf.LoadCollectionSpec(e.elfPath)
if err != nil {
return fmt.Errorf("failed to load eBPF collection spec: %w", err)
}
// Find the ring buffer map by name
_, ok := spec.Maps["rb_map"]
if !ok {
return fmt.Errorf("ring buffer map 'rb_map' not found in eBPF collection")
}
// Load collection to get loaded maps
coll, err := ebpf.NewCollection(spec)
if err != nil {
return fmt.Errorf("failed to load eBPF collection: %w", err)
}
// Access the loaded ring buffer map
loadedMap := coll.Maps["rb_map"]
if loadedMap == nil {
coll.Close()
return fmt.Errorf("ring buffer map 'rb_map' not found in loaded collection")
}
// Create ring buffer reader
rd, err := ringbuf.NewReader(loadedMap)
if err != nil {
coll.Close()
return fmt.Errorf("failed to create ring buffer: %w", err)
}
e.rd = rd
e.elfPath = "" // Don't load again on stop
// Set up signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Printf("[INFO] [kernel] [ebpf] shutdown_signal_received")
e.cancel()
e.wg.Wait()
e.rd.Close()
}()
e.wg.Add(1)
go e.consumeEvents()
return nil
}
// Stop stops the consumer
func (e *EBPFConsumer) Stop() error {
e.cancel()
e.wgLock.Lock()
e.running = false
e.wgLock.Unlock()
e.wg.Wait()
if e.rd != nil {
e.rd.Close()
}
log.Printf("[INFO] [kernel] [ebpf] consumer_stopped")
return nil
}
// consumeEvents reads from the ring buffer and forwards events to the policy checker
func (e *EBPFConsumer) consumeEvents() {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
return
default:
}
// Read event from ring buffer
record, err := e.rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
return
}
log.Printf("[ERROR] [kernel] [ebpf] ringbuf_read_failed error=%v", err)
continue
}
e.processEvent(record.RawSample)
}
}
// processEvent handles a single eBPF event
func (e *EBPFConsumer) processEvent(data []byte) {
// Decode using the struct from eBPF (must match exactly)
// Layout: timestamp(8) pid(4) pgid(4) uid(8) gid(8) parent_pid(4) comm(16) cmdline(256) parent_comm(16) magic(4) = 328 bytes
event := &redflagEvent{}
// Use unsafe to copy raw bytes into the struct
bytess := (*[328]byte)(unsafe.Pointer(event))
copy(bytess[0:8], data[0:8])
copy(bytess[8:12], data[8:12])
copy(bytess[12:16], data[12:16])
copy(bytess[16:24], data[16:24])
copy(bytess[24:32], data[24:32])
copy(bytess[32:36], data[32:36])
copy(bytess[36:52], data[36:52])
copy(bytess[52:308], data[52:308])
copy(bytess[308:324], data[308:324])
copy(bytess[324:328], data[324:328])
// Validate magic number
if event.Magic != MAGIC_NUMBER {
log.Printf("[ERROR] [kernel] [ebpf] invalid_magic magic=0x%x expected=0x%x",
event.Magic, MAGIC_NUMBER)
return
}
// Forward to rs-helper for policy decision
decision, reason, err := e.checkPolicy(event)
if err != nil {
log.Printf("[ERROR] [kernel] [ebpf] policy_check_failed error=%v", err)
return
}
if decision == "deny" {
log.Printf("[ERROR] [kernel] [ebpf] execve_deny pid=%d comm=%s reason=%s",
event.PID, event.Comm, reason)
} else {
log.Printf("[INFO] [kernel] [ebpf] execve_allowed pid=%d comm=%s",
event.PID, event.Comm)
}
}
// redflagEvent matches the eBPF struct redflag_event layout
type redflagEvent struct {
Timestamp uint64
PID uint32
PGID uint32
UID uint64
GID uint64
ParentPID uint32
Comm [16]byte
Cmdline [256]byte
ParentComm [16]byte
Magic uint32
}
// checkPolicy evaluates the policy for a package manager invocation
func (e *EBPFConsumer) checkPolicy(event *redflagEvent) (string, string, error) {
// Decode strings for policy check
comm := *(*string)(unsafe.Pointer(&event.Comm))
cmdline := *(*string)(unsafe.Pointer(&event.Cmdline))
parentComm := *(*string)(unsafe.Pointer(&event.ParentComm))
log.Printf("[DEBUG] [kernel] [ebpf] policy_check comm=%s cmdline=%s parent=%s uid=%d",
comm, cmdline, parentComm, event.UID)
// Check if this is a package manager command
isPackageManager := isPackageManager(comm)
if !isPackageManager {
log.Printf("[INFO] [kernel] [ebpf] not_package_manager comm=%s", comm)
return "allow", "not_package_manager", nil
}
// TODO: Call rs-helper via Unix socket for policy decision
// For now, return allow (agent should have rs-helper running)
return "allow", "policy_evaluation_pending", nil
}
// isPackageManager checks if a command is a known package manager
func isPackageManager(comm string) bool {
pkgManagers := []string{"apt", "apt-get", "apt-cache", "dnf", "yum", "rpm-ostree",
"npm", "pnpm", "bun", "pip", "pip3", "uv",
"docker", "crun", "containerd"}
for _, pm := range pkgManagers {
if comm == pm {
return true
}
}
return false
}
// Event represents a typed intercept event for API use
type Event struct {
Timestamp time.Time
PID uint32
PGID uint32
Comm string
Cmdline string
UID uint64
GID uint64
ParentPID uint32
ParentComm string
}
// FromBinary creates an Event from binary data (deprecated, use direct struct access)
func FromBinary(data []byte) (*Event, error) {
if len(data) < 328 {
return nil, fmt.Errorf("data too short: %d", len(data))
}
event := &redflagEvent{}
// Use unsafe to copy raw bytes into the struct
bytess := (*[328]byte)(unsafe.Pointer(event))
copy(bytess[0:8], data[0:8])
copy(bytess[8:12], data[8:12])
copy(bytess[12:16], data[12:16])
copy(bytess[16:24], data[16:24])
copy(bytess[24:32], data[24:32])
copy(bytess[32:36], data[32:36])
copy(bytess[36:52], data[36:52])
copy(bytess[52:308], data[52:308])
copy(bytess[308:324], data[308:324])
copy(bytess[324:328], data[324:328])
return &Event{
Timestamp: time.Unix(0, int64(event.Timestamp)),
PID: event.PID,
PGID: event.PGID,
Comm: string(bytes.TrimRight(event.Comm[:], "\x00")),
Cmdline: string(bytes.TrimRight(event.Cmdline[:], "\x00")),
UID: event.UID,
GID: event.GID,
ParentPID: event.ParentPID,
ParentComm: string(bytes.TrimRight(event.ParentComm[:], "\x00")),
}, nil
}

View file

@ -0,0 +1,79 @@
package kernel
import (
"context"
"log"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
type Enforcer interface {
Start(ctx context.Context) error
Stop() error
GetPackageType() string
}
type EnforcerFactory func(*config.Config) (Enforcer, error)
var enforcers = map[string]EnforcerFactory{}
func RegisterEnforcer(pkgType string, factory EnforcerFactory) {
enforcers[pkgType] = factory
}
func NewEnforcer(cfg *config.Config) (Enforcer, error) {
if !cfg.KernelEnforcement.Enabled {
return &noopEnforcer{}, nil
}
// Determine package type based on platform
pkgType := "linux"
if cfg.OS.Type == "windows" {
pkgType = "windows"
}
if factory, ok := enforcers[pkgType]; ok {
return factory(cfg)
}
log.Printf("[WARNING] [kernel] unknown_package_type=%s", pkgType)
return &noopEnforcer{}, nil
}
type noopEnforcer struct{}
func (n *noopEnforcer) Start(ctx context.Context) error { return nil }
func (n *noopEnforcer) Stop() error { return nil }
func (n *noopEnforcer) GetPackageType() string { return "noop" }
type EBPFEnforcer struct {
consumer *EBPFConsumer
}
func NewEBPFEnforcer(cfg *config.Config) (*EBPFEnforcer, error) {
consumer, err := NewEBPFConsumer(cfg)
if err != nil {
return nil, err
}
return &EBPFEnforcer{consumer: consumer}, nil
}
func (e *EBPFEnforcer) Start(ctx context.Context) error {
return e.consumer.Start()
}
func (e *EBPFEnforcer) Stop() error {
return e.consumer.Stop()
}
func (e *EBPFEnforcer) GetPackageType() string { return "linux" }
func init() {
RegisterEnforcer("linux", func(cfg *config.Config) (Enforcer, error) {
consumer, err := NewEBPFConsumer(cfg)
if err != nil {
return nil, err
}
return &EBPFEnforcer{consumer: consumer}, nil
})
}

View file

@ -0,0 +1,164 @@
package kernel
import (
"context"
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// WDACPolicy handles Windows WDAC (Windows Defender Application Control) enforcement
type WDACPolicy struct {
client *client.Client
config *config.Config
policyName string
policyHash string
}
// NewWDACPolicy creates a new WDACPolicy enforcer
func NewWDACPolicy(c *client.Client, cfg *config.Config) *WDACPolicy {
return &WDACPolicy{
client: c,
config: cfg,
policyName: "RedFlag-Package-Manager",
}
}
// GetPackageType returns the package type this enforcer handles
func (w *WDACPolicy) GetPackageType() string {
return "wdac"
}
// IsAvailable checks if WDAC is available on this system
func (w *WDACPolicy) IsAvailable() bool {
// WDAC only available on Windows
return true // Platform check done by caller
}
// CheckPolicy evaluates if a package manager invocation is allowed
func (w *WDACPolicy) CheckPolicy(packageType, packageName, packageVersion string) (bool, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Printf("[INFO] [kernel] [wdac] policy_check package=%s type=%s version=%s", packageName, packageType, packageVersion)
result, err := w.fetchPolicy(ctx)
if err != nil {
log.Printf("[ERROR] [kernel] [wdac] policy_fetch_failed package=%s error=%v", packageName, err)
return false, "policy_fetch_failed: " + err.Error(), nil
}
if result == "" {
return false, "no_policy", nil
}
// Parse policy and check if package manager is allowed
allowed, reason := w.evaluatePolicy(packageType, packageName, result)
if !allowed {
log.Printf("[ERROR] [kernel] [wdac] policy_denied package=%s reason=%s", packageName, reason)
} else {
log.Printf("[INFO] [kernel] [wdac] policy_allowed package=%s", packageName)
}
return allowed, reason, nil
}
// fetchPolicy retrieves the current WDAC policy from the server
func (w *WDACPolicy) fetchPolicy(ctx context.Context) (string, error) {
// TODO: Replace with actual WDAC COM API calls via go-ole
// For now, fetch policy hash from server and validate against local policy
hash, err := w.client.GetWdacPolicyHash(w.config.AgentID, w.config.ServerURL)
if err != nil {
log.Printf("[WARNING] [kernel] [wdac] policy_hash_fetch_skipped error=%v", err)
// For now, treat as no policy available (fail-closed)
return "", fmt.Errorf("no_policy")
}
if hash == "" {
return "", fmt.Errorf("no WDAC policy available from server")
}
if hash != w.policyHash {
log.Printf("[WARNING] [kernel] [wdac] policy_hash_mismatch local=%s server=%s", w.policyHash, hash)
// Policy needs update - return empty to trigger update
return "", fmt.Errorf("policy_hash_mismatch")
}
return w.policyHash, nil
}
// evaluatePolicy checks if the package manager is allowed by the current policy
func (w *WDACPolicy) evaluatePolicy(packageType, packageName, policyHash string) (bool, string) {
// Allowed package managers (fail-closed: deny all others)
allowed := map[string]bool{
"apt": true,
"apt-get": true,
"apt-cache": true,
"dnf": true,
"yum": true,
"rpm-ostree": true,
"npm": true,
"pnpm": true,
"bun": true,
"pip": true,
"pip3": true,
"uv": true,
"docker": true,
"crun": true,
"containerd": true,
}
if allowed[packageType] {
return true, "allowed_by_policy"
}
return false, "package_manager_not_in_policy"
}
// UpdatePolicy downloads and installs a new WDAC policy from the server
func (w *WDACPolicy) UpdatePolicy() error {
log.Printf("[INFO] [kernel] [wdac] update_policy_start")
newHash, err := w.client.GetWdacPolicyHash(w.config.AgentID, w.config.ServerURL)
if err != nil {
// For now, keep existing policy hash (no update)
log.Printf("[WARNING] [kernel] [wdac] policy_update_skipped error=%v", err)
return nil
}
if newHash == "" {
return fmt.Errorf("no policy available from server")
}
// TODO: Download policy file and apply via Set-CIPolicy
// For now, just update the hash
w.policyHash = newHash
log.Printf("[INFO] [kernel] [wdac] update_policy_complete hash=%s", newHash[:16]+"...")
return nil
}
// InstallPolicy installs a WDAC policy from a binary or XML file
func (w *WDACPolicy) InstallPolicy(policyPath string) error {
// TODO: Implement actual WDAC policy installation via COM API
// Uses Microsoft.Management.Ops or similar COM interface
log.Printf("[INFO] [kernel] [wdac] install_policy path=%s", policyPath)
return fmt.Errorf("install_policy_not_implemented_yet")
}
// GetPolicyStatus returns the current WDAC policy status
func (w *WDACPolicy) GetPolicyStatus() (string, error) {
// TODO: Query WDAC service for status
return "unknown", nil
}
// ResetPolicy resets the WDAC policy to a known-good state
func (w *WDACPolicy) ResetPolicy() error {
log.Printf("[WARNING] [kernel] [wdac] reset_policy_initiated")
return fmt.Errorf("reset_policy_not_implemented_yet")
}

View file

@ -17,6 +17,108 @@ import (
"github.com/google/uuid"
)
// ConfirmedTracker is a disk-persisted set of command IDs the server has confirmed
// as completed (via ReportLog). Used to distinguish between "command re-sent by
// server before agent confirmed" vs "command truly duplicate."
type ConfirmedTracker struct {
confirmed map[string]time.Time
mu sync.RWMutex
filePath string
}
// NewConfirmedTracker creates a tracker that persists state under statePath/confirmed_completed.json.
func NewConfirmedTracker(statePath string) *ConfirmedTracker {
return &ConfirmedTracker{
confirmed: make(map[string]time.Time),
filePath: filepath.Join(statePath, "confirmed_completed.json"),
}
}
// Load restores confirmed completions from disk. Missing file is not an error (fresh start).
func (t *ConfirmedTracker) Load() error {
t.mu.Lock()
defer t.mu.Unlock()
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(t.filePath)
if err != nil {
return fmt.Errorf("read confirmed_completed: %w", err)
}
if len(data) == 0 {
return nil
}
var loaded map[string]time.Time
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parse confirmed_completed: %w", err)
}
t.confirmed = loaded
return nil
}
// Save persists the confirmed set to disk.
func (t *ConfirmedTracker) Save() error {
t.mu.Lock()
defer t.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(t.filePath), 0o755); err != nil {
return fmt.Errorf("create confirmed dir: %w", err)
}
data, err := json.MarshalIndent(t.confirmed, "", " ")
if err != nil {
return fmt.Errorf("marshal confirmed_completed: %w", err)
}
if err := os.WriteFile(t.filePath, data, 0o600); err != nil {
return fmt.Errorf("write confirmed_completed: %w", err)
}
return nil
}
// Add records that the server confirmed a command as completed. Safe to call
// multiple times with the same ID — duplicates are a no-op (idempotent, ETHOS #4).
func (t *ConfirmedTracker) Add(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.confirmed[commandID]; exists {
return
}
t.confirmed[commandID] = time.Now().UTC()
}
// GetPending returns the current confirmed set as a slice (snapshot — safe to mutate).
func (t *ConfirmedTracker) GetPending() []string {
t.mu.RLock()
defer t.mu.RUnlock()
ids := make([]string, 0, len(t.confirmed))
for id := range t.confirmed {
ids = append(ids, id)
}
return ids
}
// Confirm removes the IDs the server confirmed. IDs not in the confirmed
// set are silently ignored — server might confirm an ID we already dropped after
// a successful prior round-trip.
func (t *ConfirmedTracker) Confirm(commandIDs []string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, id := range commandIDs {
delete(t.confirmed, id)
}
}
// Len returns the current size of the confirmed set.
func (t *ConfirmedTracker) Len() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.confirmed)
}
const (
// keyRefreshInterval is how often the agent proactively re-checks the server's primary key
keyRefreshInterval = 6 * time.Hour
@ -35,6 +137,13 @@ type CommandHandler struct {
executedIDs map[string]time.Time // cmd UUID -> execution time (F-2 fix: dedup)
executedIDsMu sync.Mutex
executedIDsPath string // Migration 033 §5: disk-persisted dedup
// confirmedCompleted tracks command IDs the server has confirmed as completed
// (via ReceiptConfirmedIDs/AcknowledgedIDs in response). Commands in this set
// are NOT rejected as duplicates even if they appear in the executed set,
// because the server may not have processed the ReportLog() yet.
confirmedCompleted map[string]time.Time // cmd UUID -> confirmation time
confirmedCompletedMu sync.Mutex
confirmedCompletedPath string // disk-persisted confirmation set
lastKeyRefresh time.Time
logger *log.Logger
}
@ -49,14 +158,18 @@ type CommandSigningConfig struct {
// state directory; passing an empty string disables disk-persisted dedup (test path).
func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
handler := &CommandHandler{
securityLogger: securityLogger,
logger: logger,
verifier: crypto.NewCommandVerifier(),
keyCache: make(map[string]ed25519.PublicKey),
executedIDs: make(map[string]time.Time),
securityLogger: securityLogger,
logger: logger,
verifier: crypto.NewCommandVerifier(),
keyCache: make(map[string]ed25519.PublicKey),
executedIDs: make(map[string]time.Time),
confirmedCompleted: make(map[string]time.Time),
confirmedCompletedMu: sync.Mutex{},
confirmedCompletedPath: "",
}
if stateDir != "" {
handler.executedIDsPath = filepath.Join(stateDir, "executed_commands.json")
handler.confirmedCompletedPath = filepath.Join(stateDir, "confirmed_completed.json")
}
// Migration 033 §5: rebuild dedup set from disk so a restart can't re-execute a
@ -71,6 +184,15 @@ func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logg
}
}
// Load confirmed completion tracker (server-acked commands)
if handler.confirmedCompletedPath != "" {
if err := handler.loadConfirmedCompleted(); err != nil {
logger.Printf("[WARNING] [agent] [cmd_handler] load_confirmed_completed_failed path=%q error=%v", handler.confirmedCompletedPath, err)
} else {
logger.Printf("[INFO] [agent] [cmd_handler] confirmed_completed_loaded count=%d", len(handler.confirmedCompleted))
}
}
// Pre-load cached public key if command signing is enabled
if cfg.CommandSigning.Enabled {
if pubKey, err := crypto.LoadCachedPublicKey(); err == nil {
@ -109,6 +231,53 @@ func (h *CommandHandler) loadExecutedIDs() error {
return nil
}
// loadConfirmedCompleted restores the server-confirmed completion set from disk.
// Called once during construction. Caller holds no lock.
func (h *CommandHandler) loadConfirmedCompleted() error {
if _, err := os.Stat(h.confirmedCompletedPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(h.confirmedCompletedPath)
if err != nil {
return fmt.Errorf("read confirmed_completed: %w", err)
}
if len(data) == 0 {
return nil
}
var loaded map[string]time.Time
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parse confirmed_completed: %w", err)
}
h.confirmedCompletedMu.Lock()
h.confirmedCompleted = loaded
h.confirmedCompletedMu.Unlock()
return nil
}
// saveConfirmedCompleted persists the server-confirmed completion set to disk.
// Caller MUST hold confirmedCompletedMu.
func (h *CommandHandler) saveConfirmedCompletedLocked() error {
if h.confirmedCompletedPath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(h.confirmedCompletedPath), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
data, err := json.MarshalIndent(h.confirmedCompleted, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp := h.confirmedCompletedPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write: %w", err)
}
if err := os.Rename(tmp, h.confirmedCompletedPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// saveExecutedIDs persists the current dedup set. Caller MUST hold executedIDsMu.
// Errors are logged by the caller — best-effort persistence; the in-memory set is
// the authoritative source within a single process lifetime.

View file

@ -2,9 +2,14 @@ package orchestrator
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
@ -38,7 +43,6 @@ func (s *DockerScanner) IsAvailable() bool {
return false
}
// Try to ping Docker daemon
if s.client != nil {
_, err := s.client.Ping(context.Background())
return err == nil
@ -47,35 +51,30 @@ func (s *DockerScanner) IsAvailable() bool {
return false
}
// ScanDocker scans for available Docker image updates and returns proper DockerImage data
func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
// Scan collects Docker image info and returns UpdateReportItems with full data in Metadata
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
ctx := context.Background()
// List all containers
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var images []DockerImage
var items []client.UpdateReportItem
seenImages := make(map[string]bool)
for _, c := range containers {
imageName := c.Image
// Skip if we've already checked this image
if seenImages[imageName] {
continue
}
seenImages[imageName] = true
// Get current image details
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
if err != nil {
continue
}
// Parse image name and tag
parts := strings.Split(imageName, ":")
baseImage := parts[0]
currentTag := "latest"
@ -83,10 +82,8 @@ func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
currentTag = parts[1]
}
// Check if update is available by comparing with registry
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
// Extract short digest for display (first 12 chars of sha256 hash)
localDigest := imageInspect.ID
localShortDigest := ""
if len(localDigest) > 7 {
@ -104,139 +101,61 @@ func (s *DockerScanner) ScanDocker() ([]DockerImage, error) {
}
}
// Determine severity based on update status
severity := "low"
if hasUpdate {
severity = "moderate"
}
// Extract image labels
labels := make(map[string]string)
if imageInspect.Config != nil {
labels = imageInspect.Config.Labels
}
// Get image size
sizeBytes := int64(0)
if len(imageInspect.RootFS.Layers) > 0 {
sizeBytes = imageInspect.Size
}
// Parse the creation time - imageInspect.Created is already a string
createdAt := imageInspect.Created
if createdAt == "" {
createdAt = time.Now().UTC().Format(time.RFC3339)
}
image := DockerImage{
ImageName: imageName,
ImageTag: currentTag,
ImageID: localShortDigest,
RepositorySource: baseImage,
SizeBytes: sizeBytes,
CreatedAt: createdAt,
HasUpdate: hasUpdate,
LatestImageID: remoteShortDigest,
Severity: severity,
Labels: labels,
items = append(items, client.UpdateReportItem{
PackageType: "docker_image",
PackageName: imageName,
PackageDescription: fmt.Sprintf("Docker image %s:%s", imageName, currentTag),
CurrentVersion: localShortDigest,
AvailableVersion: remoteShortDigest,
Severity: severity,
RepositorySource: baseImage,
SizeBytes: sizeBytes,
Metadata: map[string]interface{}{
"container_id": c.ID[:12],
"container_names": c.Names,
"container_state": c.State,
"image_created": imageInspect.Created,
"image_name": imageName,
"image_tag": currentTag,
"image_id": localShortDigest,
"latest_image_id": remoteShortDigest,
"has_update": hasUpdate,
"repository": baseImage,
"size_bytes": sizeBytes,
"created_at": createdAt,
"labels": labels,
"container_id": c.ID[:12],
"container_names": c.Names,
"container_state": c.State,
"image_created": imageInspect.Created,
"local_full_digest": localDigest,
"remote_digest": remoteDigest,
"remote_digest": remoteDigest,
},
}
images = append(images, image)
}
return images, nil
}
// Name returns the scanner name
func (s *DockerScanner) Name() string {
return "Docker Image Scanner"
}
// --- Legacy Compatibility Methods ---
// Scan scans for available Docker image updates (LEGACY)
// This method is kept for backwards compatibility with the old Scanner interface
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
images, err := s.ScanDocker()
if err != nil {
return nil, err
}
// Convert proper DockerImage back to legacy UpdateReportItem format
var items []client.UpdateReportItem
for _, image := range images {
if image.HasUpdate { // Only include images that have updates
item := client.UpdateReportItem{
PackageType: "docker_image",
PackageName: image.ImageName,
PackageDescription: fmt.Sprintf("Docker Image: %s", image.ImageName),
CurrentVersion: image.ImageID,
AvailableVersion: image.LatestImageID,
Severity: image.Severity,
RepositorySource: image.RepositorySource,
Metadata: image.Metadata,
}
items = append(items, item)
}
})
}
return items, nil
}
// --- Typed Scanner Implementation ---
// GetType returns the scanner type
func (s *DockerScanner) GetType() ScannerType {
return ScannerTypeDocker
}
// ScanTyped returns typed results (new implementation)
func (s *DockerScanner) ScanTyped() (TypedScannerResult, error) {
startTime := time.Now()
images, err := s.ScanDocker()
if err != nil {
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeDocker,
Error: err,
Status: "failed",
Duration: time.Since(startTime).Milliseconds(),
}, err
}
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeDocker,
DockerData: images,
Status: "success",
Duration: time.Since(startTime).Milliseconds(),
}, nil
}
// checkForUpdate checks if a newer image version is available by comparing digests
// Returns (hasUpdate bool, remoteDigest string)
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
// Get remote digest from registry
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
if err != nil {
// If we can't check the registry, log the error but don't report an update
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
return false, ""
}
// Compare digests
hasUpdate := currentID != remoteDigest
return hasUpdate, remoteDigest
// Name returns the scanner name
func (s *DockerScanner) Name() string {
return "Docker Image Scanner"
}
// Close closes the Docker client
@ -247,20 +166,228 @@ func (s *DockerScanner) Close() error {
return nil
}
// --- Registry Client (simplified for this implementation) ---
// checkForUpdate checks if a newer image version is available by comparing digests
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
if err != nil {
log.Printf("[WARNING] [agent] [docker] registry_check_failed image=%s:%s error=%v", imageName, tag, err)
return false, ""
}
// RegistryClient handles Docker registry API interactions
type RegistryClient struct{}
// NewRegistryClient creates a new registry client
func NewRegistryClient() *RegistryClient {
return &RegistryClient{}
return currentID != remoteDigest, remoteDigest
}
// GetRemoteDigest gets the remote digest for an image from the registry
func (r *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
// This is a simplified implementation
// In a real implementation, you would query Docker Hub or the appropriate registry
// For now, return an empty string to indicate no remote digest available
return "", fmt.Errorf("registry client not implemented")
}
// --- Registry Client ---
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
type RegistryClient struct {
httpClient *http.Client
cache *manifestCache
}
// manifestCache stores registry responses to avoid hitting rate limits
type manifestCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
digest string
expiresAt time.Time
}
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
type ManifestResponse struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config struct {
Digest string `json:"digest"`
} `json:"config"`
}
// DockerHubTokenResponse represents the authentication token response from Docker Hub
type DockerHubTokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
IssuedAt time.Time `json:"issued_at"`
}
// NewRegistryClient creates a new registry client with caching
func NewRegistryClient() *RegistryClient {
return &RegistryClient{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
cache: &manifestCache{
entries: make(map[string]*cacheEntry),
},
}
}
// GetRemoteDigest fetches the digest of a remote image from the registry
// Returns the digest string (e.g., "sha256:abc123...") or an error
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
registry, repository := parseImageName(imageName)
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
if digest := c.cache.get(cacheKey); digest != "" {
return digest, nil
}
token, err := c.getAuthToken(ctx, registry, repository)
if err != nil {
return "", fmt.Errorf("failed to get auth token: %w", err)
}
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
if err != nil {
return "", fmt.Errorf("failed to fetch manifest: %w", err)
}
c.cache.set(cacheKey, digest, 5*time.Minute)
return digest, nil
}
// parseImageName splits an image name into registry and repository
// Examples:
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
func parseImageName(imageName string) (registry, repository string) {
parts := strings.Split(imageName, "/")
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
registry = parts[0]
repository = strings.Join(parts[1:], "/")
} else if len(parts) == 1 {
registry = "registry-1.docker.io"
repository = "library/" + parts[0]
} else {
registry = "registry-1.docker.io"
repository = imageName
}
return registry, repository
}
// getAuthToken obtains an authentication token for the registry
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
if registry == "registry-1.docker.io" {
return c.getDockerHubToken(ctx, repository)
}
return "", nil
}
// getDockerHubToken obtains a token from Docker Hub's authentication service
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
authURL := fmt.Sprintf(
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
repository,
)
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
if err != nil {
return "", err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp DockerHubTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
}
if tokenResp.Token != "" {
return tokenResp.Token, nil
}
return tokenResp.AccessToken, nil
}
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
}
if resp.StatusCode == http.StatusUnauthorized {
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
}
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
return digest, nil
}
var manifest ManifestResponse
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return "", fmt.Errorf("failed to decode manifest: %w", err)
}
if manifest.Config.Digest == "" {
return "", fmt.Errorf("manifest does not contain a config digest")
}
return manifest.Config.Digest, nil
}
// manifestCache methods
func (mc *manifestCache) get(key string) string {
mc.mu.RLock()
defer mc.mu.RUnlock()
entry, exists := mc.entries[key]
if !exists {
return ""
}
if time.Now().After(entry.expiresAt) {
delete(mc.entries, key)
return ""
}
return entry.digest
}
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.entries[key] = &cacheEntry{
digest: digest,
expiresAt: time.Now().Add(ttl),
}
}

View file

@ -1,112 +0,0 @@
package orchestrator
import (
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// StorageMetric represents a single storage/disk metric
type StorageMetric struct {
Mountpoint string `json:"mountpoint"`
Filesystem string `json:"filesystem"`
Device string `json:"device"`
DiskType string `json:"disk_type"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
AvailableBytes int64 `json:"available_bytes"`
UsedPercent float64 `json:"used_percent"`
IsRoot bool `json:"is_root"`
IsLargest bool `json:"is_largest"`
Severity string `json:"severity"`
Metadata map[string]interface{} `json:"metadata"`
}
// SystemMetric represents a single system metric (CPU, memory, etc.)
type SystemMetric struct {
MetricName string `json:"metric_name"`
MetricType string `json:"metric_type"` // "cpu", "memory", "processes", "uptime", etc.
CurrentValue string `json:"current_value"`
AvailableValue string `json:"available_value"`
Severity string `json:"severity"`
Metadata map[string]interface{} `json:"metadata"`
}
// DockerImage represents a single Docker image
type DockerImage struct {
ImageName string `json:"image_name"`
ImageTag string `json:"image_tag"`
ImageID string `json:"image_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
HasUpdate bool `json:"has_update"`
LatestImageID string `json:"latest_image_id"`
Severity string `json:"severity"`
Labels map[string]string `json:"labels"`
Metadata map[string]interface{} `json:"metadata"`
}
// PackageUpdate represents an actual software package update (legacy, for package scanners only)
type PackageUpdate = client.UpdateReportItem
// --- Scanner Interfaces ---
// StorageScannerInterface handles storage/disk metrics scanning
type StorageScannerInterface interface {
IsAvailable() bool
ScanStorage() ([]StorageMetric, error)
Name() string
}
// SystemScannerInterface handles system metrics scanning
type SystemScannerInterface interface {
IsAvailable() bool
ScanSystem() ([]SystemMetric, error)
Name() string
}
// DockerScannerInterface handles Docker image scanning
type DockerScannerInterface interface {
IsAvailable() bool
ScanDocker() ([]DockerImage, error)
Name() string
}
// PackageScannerInterface handles package update scanning (legacy)
type PackageScannerInterface interface {
IsAvailable() bool
ScanPackages() ([]PackageUpdate, error)
Name() string
}
// --- Unified Scanner Types for Backwards Compatibility ---
// ScannerType represents the type of data a scanner returns
type ScannerType string
const (
ScannerTypeStorage ScannerType = "storage"
ScannerTypeSystem ScannerType = "system"
ScannerTypeDocker ScannerType = "docker"
ScannerTypePackage ScannerType = "package"
)
// TypedScannerResult represents the result of any type of scanner
type TypedScannerResult struct {
ScannerName string
ScannerType ScannerType
StorageData []StorageMetric
SystemData []SystemMetric
DockerData []DockerImage
PackageData []PackageUpdate
Error error
Duration int64 // milliseconds
Status string
}
// TypedScanner is a unified interface that can return any type of data
type TypedScanner interface {
IsAvailable() bool
GetType() ScannerType
Scan() (TypedScannerResult, error)
Name() string
}

View file

@ -1,407 +0,0 @@
package orchestrator
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
)
// === Type Conversion Functions ===
// These functions convert scanner-specific metrics to the generic UpdateReportItem format
// This maintains compatibility with the existing Scanner interface while preserving data
// convertStorageToUpdates converts StorageMetric slices to UpdateReportItem format
func convertStorageToUpdates(metrics []StorageMetric) []client.UpdateReportItem {
log.Printf("[HISTORY] [agent] [storage] converting %d storage metrics to update items timestamp=%s",
len(metrics), time.Now().Format(time.RFC3339))
updates := make([]client.UpdateReportItem, 0, len(metrics))
for _, metric := range metrics {
update := client.UpdateReportItem{
// Map storage metrics to package-like structure for compatibility
PackageType: "storage",
PackageName: metric.Mountpoint,
PackageDescription: fmt.Sprintf("Storage metrics for %s (%s)", metric.Mountpoint, metric.Filesystem),
CurrentVersion: fmt.Sprintf("%.1f%% used", metric.UsedPercent),
AvailableVersion: fmt.Sprintf("%.1f GB free", float64(metric.AvailableBytes)/1024/1024/1024),
Severity: metric.Severity,
RepositorySource: metric.Device,
SizeBytes: metric.TotalBytes,
Metadata: map[string]interface{}{
"mountpoint": metric.Mountpoint,
"filesystem": metric.Filesystem,
"device": metric.Device,
"disk_type": metric.DiskType,
"total_bytes": metric.TotalBytes,
"used_bytes": metric.UsedBytes,
"available_bytes": metric.AvailableBytes,
"used_percent": metric.UsedPercent,
"is_root": metric.IsRoot,
"is_largest": metric.IsLargest,
},
}
updates = append(updates, update)
}
log.Printf("[HISTORY] [agent] [storage] Converted %d storage metrics to update items timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates
}
// convertSystemToUpdates converts SystemMetric slices to UpdateReportItem format
func convertSystemToUpdates(metrics []SystemMetric) []client.UpdateReportItem {
log.Printf("[HISTORY] [agent] [system] converting %d system metrics to update items timestamp=%s",
len(metrics), time.Now().Format(time.RFC3339))
updates := make([]client.UpdateReportItem, 0, len(metrics))
for _, metric := range metrics {
update := client.UpdateReportItem{
// Map system metrics to package-like structure for compatibility
PackageType: "system",
PackageName: metric.MetricName,
PackageDescription: fmt.Sprintf("System metric %s (%s)", metric.MetricName, metric.MetricType),
CurrentVersion: metric.CurrentValue,
AvailableVersion: metric.AvailableValue,
Severity: metric.Severity,
RepositorySource: metric.MetricType,
Metadata: map[string]interface{}{
"metric_name": metric.MetricName,
"metric_type": metric.MetricType,
"current_value": metric.CurrentValue,
"available_value": metric.AvailableValue,
},
}
updates = append(updates, update)
}
log.Printf("[HISTORY] [agent] [system] Converted %d system metrics to update items timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates
}
// convertDockerToUpdates converts DockerImage slices to UpdateReportItem format
func convertDockerToUpdates(images []DockerImage) []client.UpdateReportItem {
log.Printf("[HISTORY] [agent] [docker] converting %d docker images to update items timestamp=%s",
len(images), time.Now().Format(time.RFC3339))
updates := make([]client.UpdateReportItem, 0, len(images))
for _, image := range images {
update := client.UpdateReportItem{
// Map Docker images to package structure
PackageType: "docker",
PackageName: image.ImageName,
PackageDescription: fmt.Sprintf("Docker image %s:%s", image.ImageName, image.ImageTag),
CurrentVersion: image.ImageTag,
AvailableVersion: "latest",
Severity: image.Severity,
RepositorySource: image.RepositorySource,
SizeBytes: image.SizeBytes,
Metadata: map[string]interface{}{
"image_name": image.ImageName,
"image_tag": image.ImageTag,
"image_id": image.ImageID,
"repository": image.RepositorySource,
"size_bytes": image.SizeBytes,
"created_at": image.CreatedAt,
"has_update": image.HasUpdate,
"latest_image_id": image.LatestImageID,
"labels": image.Labels,
},
}
updates = append(updates, update)
}
log.Printf("[HISTORY] [agent] [docker] Converted %d docker images to update items timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates
}
// APTScannerWrapper wraps the APT scanner to implement the Scanner interface
type APTScannerWrapper struct {
scanner *scanner.APTScanner
}
func NewAPTScannerWrapper(s *scanner.APTScanner) *APTScannerWrapper {
return &APTScannerWrapper{scanner: s}
}
func (w *APTScannerWrapper) IsAvailable() bool {
return w.scanner.IsAvailable()
}
func (w *APTScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [apt] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("apt scanner is nil")
log.Printf("[ERROR] [agent] [apt] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates, err := w.scanner.Scan()
if err != nil {
log.Printf("[ERROR] [agent] [apt] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
log.Printf("[HISTORY] [agent] [apt] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *APTScannerWrapper) Name() string {
return "APT Update Scanner"
}
// DNFScannerWrapper wraps the DNF scanner to implement the Scanner interface
type DNFScannerWrapper struct {
scanner *scanner.DNFScanner
}
func NewDNFScannerWrapper(s *scanner.DNFScanner) *DNFScannerWrapper {
return &DNFScannerWrapper{scanner: s}
}
func (w *DNFScannerWrapper) IsAvailable() bool {
return w.scanner.IsAvailable()
}
func (w *DNFScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [dnf] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("dnf scanner is nil")
log.Printf("[ERROR] [agent] [dnf] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates, err := w.scanner.Scan()
if err != nil {
log.Printf("[ERROR] [agent] [dnf] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
log.Printf("[HISTORY] [agent] [dnf] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *DNFScannerWrapper) Name() string {
return "DNF Update Scanner"
}
// DockerScannerWrapper wraps the Docker scanner to implement the Scanner interface
type DockerScannerWrapper struct {
scanner *scanner.DockerScanner
}
func NewDockerScannerWrapper(s *scanner.DockerScanner) *DockerScannerWrapper {
return &DockerScannerWrapper{scanner: s}
}
func (w *DockerScannerWrapper) IsAvailable() bool {
if w.scanner == nil {
return false
}
return w.scanner.IsAvailable()
}
func (w *DockerScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [docker] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("docker scanner is nil")
log.Printf("[ERROR] [agent] [docker] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates, err := w.scanner.Scan()
if err != nil {
log.Printf("[ERROR] [agent] [docker] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
log.Printf("[HISTORY] [agent] [docker] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *DockerScannerWrapper) Name() string {
return "Docker Image Update Scanner"
}
// WindowsUpdateScannerWrapper wraps the Windows Update scanner to implement the Scanner interface
type WindowsUpdateScannerWrapper struct {
scanner *scanner.WindowsUpdateScanner
}
func NewWindowsUpdateScannerWrapper(s *scanner.WindowsUpdateScanner) *WindowsUpdateScannerWrapper {
return &WindowsUpdateScannerWrapper{scanner: s}
}
func (w *WindowsUpdateScannerWrapper) IsAvailable() bool {
return w.scanner.IsAvailable()
}
func (w *WindowsUpdateScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [windows] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("windows update scanner is nil")
log.Printf("[ERROR] [agent] [windows] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates, err := w.scanner.Scan()
if err != nil {
log.Printf("[ERROR] [agent] [windows] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
log.Printf("[HISTORY] [agent] [windows] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *WindowsUpdateScannerWrapper) Name() string {
return "Windows Update Scanner"
}
// WingetScannerWrapper wraps the Winget scanner to implement the Scanner interface
type WingetScannerWrapper struct {
scanner *scanner.WingetScanner
}
func NewWingetScannerWrapper(s *scanner.WingetScanner) *WingetScannerWrapper {
return &WingetScannerWrapper{scanner: s}
}
func (w *WingetScannerWrapper) IsAvailable() bool {
return w.scanner.IsAvailable()
}
func (w *WingetScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [winget] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("winget scanner is nil")
log.Printf("[ERROR] [agent] [winget] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates, err := w.scanner.Scan()
if err != nil {
log.Printf("[ERROR] [agent] [winget] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
log.Printf("[HISTORY] [agent] [winget] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *WingetScannerWrapper) Name() string {
return "Winget Package Update Scanner"
}
// StorageScannerWrapper wraps the Storage scanner to implement the Scanner interface
type StorageScannerWrapper struct {
scanner *StorageScanner
}
func NewStorageScannerWrapper(s *StorageScanner) *StorageScannerWrapper {
return &StorageScannerWrapper{scanner: s}
}
func (w *StorageScannerWrapper) IsAvailable() bool {
return w.scanner.IsAvailable()
}
func (w *StorageScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [storage] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("storage scanner is nil")
log.Printf("[ERROR] [agent] [storage] scan failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
metrics, err := w.scanner.ScanStorage()
if err != nil {
log.Printf("[ERROR] [agent] [storage] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates := convertStorageToUpdates(metrics)
log.Printf("[HISTORY] [agent] [storage] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *StorageScannerWrapper) Name() string {
return w.scanner.Name()
}
// SystemScannerWrapper wraps the System scanner to implement the Scanner interface
type SystemScannerWrapper struct {
scanner *SystemScanner
}
func NewSystemScannerWrapper(s *SystemScanner) *SystemScannerWrapper {
return &SystemScannerWrapper{scanner: s}
}
func (w *SystemScannerWrapper) IsAvailable() bool {
return w.scanner.IsAvailable()
}
func (w *SystemScannerWrapper) Scan() ([]client.UpdateReportItem, error) {
log.Printf("[HISTORY] [agent] [system] starting scan via wrapper timestamp=%s",
time.Now().Format(time.RFC3339))
if w.scanner == nil {
err := fmt.Errorf("system scanner is nil")
log.Printf("[ERROR] [agent] [system] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
metrics, err := w.scanner.ScanSystem()
if err != nil {
log.Printf("[ERROR] [agent] [system] scan_failed error=\"%v\" timestamp=%s",
err, time.Now().Format(time.RFC3339))
return nil, err
}
updates := convertSystemToUpdates(metrics)
log.Printf("[HISTORY] [agent] [system] scan_completed items=%d timestamp=%s",
len(updates), time.Now().Format(time.RFC3339))
return updates, nil
}
func (w *SystemScannerWrapper) Name() string {
return w.scanner.Name()
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/system"
)
@ -24,8 +25,8 @@ func (s *StorageScanner) IsAvailable() bool {
return true
}
// ScanStorage collects disk usage information and returns proper storage metrics
func (s *StorageScanner) ScanStorage() ([]StorageMetric, error) {
// Scan collects disk usage and returns UpdateReportItems with full data in Metadata
func (s *StorageScanner) Scan() ([]client.UpdateReportItem, error) {
sysInfo, err := system.GetSystemInfo(s.agentVersion)
if err != nil {
return nil, fmt.Errorf("failed to get system info: %w", err)
@ -35,82 +36,51 @@ func (s *StorageScanner) ScanStorage() ([]StorageMetric, error) {
return nil, fmt.Errorf("no disk information available")
}
// Convert disk info to proper StorageMetric format
var metrics []StorageMetric
var items []client.UpdateReportItem
for _, disk := range sysInfo.DiskInfo {
metric := StorageMetric{
Mountpoint: disk.Mountpoint,
Filesystem: disk.Filesystem,
Device: disk.Device,
DiskType: disk.DiskType,
TotalBytes: int64(disk.Total),
UsedBytes: int64(disk.Used),
AvailableBytes: int64(disk.Available),
UsedPercent: disk.UsedPercent,
IsRoot: disk.IsRoot,
IsLargest: disk.IsLargest,
Severity: determineDiskSeverity(disk.UsedPercent),
severity := "low"
switch {
case disk.UsedPercent >= 95:
severity = "critical"
case disk.UsedPercent >= 90:
severity = "important"
case disk.UsedPercent >= 80:
severity = "moderate"
}
item := client.UpdateReportItem{
PackageType: "storage",
PackageName: disk.Mountpoint,
PackageDescription: fmt.Sprintf("Storage metrics for %s (%s)", disk.Mountpoint, disk.Filesystem),
CurrentVersion: fmt.Sprintf("%.1f%% used", disk.UsedPercent),
AvailableVersion: fmt.Sprintf("%.1f GB free", float64(disk.Available)/1024/1024/1024),
Severity: severity,
RepositorySource: disk.Device,
SizeBytes: int64(disk.Total),
Metadata: map[string]interface{}{
"agent_version": s.agentVersion,
"collected_at": time.Now().UTC().Format(time.RFC3339),
"mountpoint": disk.Mountpoint,
"device": disk.Device,
"disk_type": disk.DiskType,
"filesystem": disk.Filesystem,
"total_bytes": int64(disk.Total),
"used_bytes": int64(disk.Used),
"available_bytes": int64(disk.Available),
"used_percent": disk.UsedPercent,
"is_root": disk.IsRoot,
"is_largest": disk.IsLargest,
"severity": severity,
"agent_version": s.agentVersion,
"collected_at": time.Now().UTC().Format(time.RFC3339),
},
}
metrics = append(metrics, metric)
items = append(items, item)
}
return metrics, nil
return items, nil
}
// Name returns the scanner name
func (s *StorageScanner) Name() string {
return "Disk Usage Reporter"
}
// --- Typed Scanner Implementation ---
// GetType returns the scanner type
func (s *StorageScanner) GetType() ScannerType {
return ScannerTypeStorage
}
// ScanTyped returns typed results (new implementation)
func (s *StorageScanner) ScanTyped() (TypedScannerResult, error) {
startTime := time.Now()
defer func() {
// Duration will be set at the end
}()
metrics, err := s.ScanStorage()
if err != nil {
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeStorage,
Error: err,
Status: "failed",
Duration: time.Since(startTime).Milliseconds(),
}, err
}
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeStorage,
StorageData: metrics,
Status: "success",
Duration: time.Since(startTime).Milliseconds(),
}, nil
}
// determineDiskSeverity returns severity based on disk usage percentage
func determineDiskSeverity(usedPercent float64) string {
switch {
case usedPercent >= 95:
return "critical"
case usedPercent >= 90:
return "important"
case usedPercent >= 80:
return "moderate"
default:
return "low"
}
}

View file

@ -2,7 +2,6 @@ package orchestrator
import (
"fmt"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/system"
@ -25,167 +24,116 @@ func (s *SystemScanner) IsAvailable() bool {
return true
}
// ScanSystem collects system information and returns proper system metrics
func (s *SystemScanner) ScanSystem() ([]SystemMetric, error) {
// Scan collects system information and returns UpdateReportItems with full data in Metadata
func (s *SystemScanner) Scan() ([]client.UpdateReportItem, error) {
sysInfo, err := system.GetSystemInfo(s.agentVersion)
if err != nil {
return nil, fmt.Errorf("failed to get system info: %w", err)
}
// Convert system info to proper SystemMetric format
var metrics []SystemMetric
var items []client.UpdateReportItem
// CPU info metric
cpuMetric := SystemMetric{
MetricName: "system-cpu",
MetricType: "cpu",
CurrentValue: fmt.Sprintf("%d cores, %d threads", sysInfo.CPUInfo.Cores, sysInfo.CPUInfo.Threads),
AvailableValue: sysInfo.CPUInfo.ModelName,
Severity: "low",
// CPU
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-cpu",
PackageDescription: "System CPU information",
CurrentVersion: fmt.Sprintf("%d cores, %d threads", sysInfo.CPUInfo.Cores, sysInfo.CPUInfo.Threads),
AvailableVersion: sysInfo.CPUInfo.ModelName,
Severity: "low",
RepositorySource: "cpu",
Metadata: map[string]interface{}{
"metric_name": "system-cpu",
"metric_type": "cpu",
"cpu_model": sysInfo.CPUInfo.ModelName,
"cpu_cores": fmt.Sprintf("%d", sysInfo.CPUInfo.Cores),
"cpu_threads": fmt.Sprintf("%d", sysInfo.CPUInfo.Threads),
},
}
metrics = append(metrics, cpuMetric)
})
// Memory info metric
memMetric := SystemMetric{
MetricName: "system-memory",
MetricType: "memory",
CurrentValue: fmt.Sprintf("%.1f%% used", sysInfo.MemoryInfo.UsedPercent),
AvailableValue: fmt.Sprintf("%d GB total", sysInfo.MemoryInfo.Total/(1024*1024*1024)),
Severity: determineMemorySeverity(sysInfo.MemoryInfo.UsedPercent),
// Memory
memSeverity := "low"
switch {
case sysInfo.MemoryInfo.UsedPercent >= 95:
memSeverity = "critical"
case sysInfo.MemoryInfo.UsedPercent >= 90:
memSeverity = "important"
case sysInfo.MemoryInfo.UsedPercent >= 80:
memSeverity = "moderate"
}
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-memory",
PackageDescription: "System memory information",
CurrentVersion: fmt.Sprintf("%.1f%% used", sysInfo.MemoryInfo.UsedPercent),
AvailableVersion: fmt.Sprintf("%d GB total", sysInfo.MemoryInfo.Total/(1024*1024*1024)),
Severity: memSeverity,
RepositorySource: "memory",
Metadata: map[string]interface{}{
"memory_total": fmt.Sprintf("%d", sysInfo.MemoryInfo.Total),
"memory_used": fmt.Sprintf("%d", sysInfo.MemoryInfo.Used),
"memory_available": fmt.Sprintf("%d", sysInfo.MemoryInfo.Available),
"memory_used_percent": fmt.Sprintf("%.1f", sysInfo.MemoryInfo.UsedPercent),
"metric_name": "system-memory",
"metric_type": "memory",
"memory_total": fmt.Sprintf("%d", sysInfo.MemoryInfo.Total),
"memory_used": fmt.Sprintf("%d", sysInfo.MemoryInfo.Used),
"memory_available": fmt.Sprintf("%d", sysInfo.MemoryInfo.Available),
"memory_used_percent": fmt.Sprintf("%.1f", sysInfo.MemoryInfo.UsedPercent),
},
}
metrics = append(metrics, memMetric)
})
// Process count metric
processMetric := SystemMetric{
MetricName: "system-processes",
MetricType: "processes",
CurrentValue: fmt.Sprintf("%d processes", sysInfo.RunningProcesses),
AvailableValue: "n/a",
Severity: "low",
// Processes
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-processes",
PackageDescription: "Running processes",
CurrentVersion: fmt.Sprintf("%d processes", sysInfo.RunningProcesses),
AvailableVersion: "n/a",
Severity: "low",
RepositorySource: "processes",
Metadata: map[string]interface{}{
"metric_name": "system-processes",
"metric_type": "processes",
"process_count": fmt.Sprintf("%d", sysInfo.RunningProcesses),
},
}
metrics = append(metrics, processMetric)
})
// Uptime metric
uptimeMetric := SystemMetric{
MetricName: "system-uptime",
MetricType: "uptime",
CurrentValue: sysInfo.Uptime,
AvailableValue: "n/a",
Severity: "low",
// Uptime
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-uptime",
PackageDescription: "System uptime",
CurrentVersion: sysInfo.Uptime,
AvailableVersion: "n/a",
Severity: "low",
RepositorySource: "uptime",
Metadata: map[string]interface{}{
"uptime": sysInfo.Uptime,
"metric_name": "system-uptime",
"metric_type": "uptime",
"uptime": sysInfo.Uptime,
},
}
metrics = append(metrics, uptimeMetric)
})
// Reboot required metric (if applicable)
// Reboot required
if sysInfo.RebootRequired {
rebootMetric := SystemMetric{
MetricName: "system-reboot",
MetricType: "reboot",
CurrentValue: "required",
AvailableValue: "n/a",
Severity: "important",
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-reboot",
PackageDescription: "System reboot status",
CurrentVersion: "required",
AvailableVersion: "n/a",
Severity: "important",
RepositorySource: "reboot",
Metadata: map[string]interface{}{
"metric_name": "system-reboot",
"metric_type": "reboot",
"reboot_required": "true",
"reboot_reason": sysInfo.RebootReason,
"reboot_reason": sysInfo.RebootReason,
},
}
metrics = append(metrics, rebootMetric)
})
}
return metrics, nil
return items, nil
}
// Name returns the scanner name
func (s *SystemScanner) Name() string {
return "System Metrics Reporter"
}
// --- Legacy Compatibility Methods ---
// Scan collects system information and returns it as "updates" for reporting (LEGACY)
// This method is kept for backwards compatibility with the old Scanner interface
func (s *SystemScanner) Scan() ([]client.UpdateReportItem, error) {
metrics, err := s.ScanSystem()
if err != nil {
return nil, err
}
// Convert proper SystemMetric back to legacy UpdateReportItem format
var items []client.UpdateReportItem
for _, metric := range metrics {
item := client.UpdateReportItem{
PackageName: metric.MetricName,
CurrentVersion: metric.CurrentValue,
AvailableVersion: metric.AvailableValue,
PackageType: "system",
Severity: metric.Severity,
PackageDescription: fmt.Sprintf("System %s: %s", metric.MetricType, metric.MetricName),
Metadata: metric.Metadata,
}
items = append(items, item)
}
return items, nil
}
// --- Typed Scanner Implementation ---
// GetType returns the scanner type
func (s *SystemScanner) GetType() ScannerType {
return ScannerTypeSystem
}
// ScanTyped returns typed results (new implementation)
func (s *SystemScanner) ScanTyped() (TypedScannerResult, error) {
startTime := time.Now()
metrics, err := s.ScanSystem()
if err != nil {
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeSystem,
Error: err,
Status: "failed",
Duration: time.Since(startTime).Milliseconds(),
}, err
}
return TypedScannerResult{
ScannerName: s.Name(),
ScannerType: ScannerTypeSystem,
SystemData: metrics,
Status: "success",
Duration: time.Since(startTime).Milliseconds(),
}, nil
}
// determineMemorySeverity returns severity based on memory usage percentage
func determineMemorySeverity(usedPercent float64) string {
switch {
case usedPercent >= 95:
return "critical"
case usedPercent >= 90:
return "important"
case usedPercent >= 80:
return "moderate"
default:
return "low"
}
}

View file

@ -19,6 +19,8 @@ func NewAPTScanner() *APTScanner {
return &APTScanner{}
}
func (s *APTScanner) Name() string { return "APT Update Scanner" }
// IsAvailable checks if APT is available on this system
func (s *APTScanner) IsAvailable() bool {
_, err := exec.LookPath("apt")

View file

@ -1,5 +1,9 @@
package scanner
import (
"os/exec"
)
// DetectAvailable returns the package-manager / Docker scanners that are
// usable on the current host. The check is stateless and side-effect free
// (no config writes, no network) so it is safe to call from both the
@ -22,7 +26,8 @@ func DetectAvailable() []string {
if NewWindowsUpdateScanner().IsAvailable() {
scanners = append(scanners, "windows")
}
if dockerScanner, _ := NewDockerScanner(); dockerScanner != nil && dockerScanner.IsAvailable() {
// Inline Docker availability check — avoids importing orchestrator (circular).
if _, err := exec.LookPath("docker"); err == nil {
scanners = append(scanners, "docker")
}

View file

@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"fmt"
"log"
"os/exec"
"regexp"
"strings"
@ -19,6 +20,8 @@ func NewDNFScanner() *DNFScanner {
return &DNFScanner{}
}
func (s *DNFScanner) Name() string { return "DNF Update Scanner" }
// IsAvailable checks if DNF is available on this system
func (s *DNFScanner) IsAvailable() bool {
_, err := exec.LookPath("dnf")
@ -46,8 +49,8 @@ func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
// Regex to parse dnf check-update output:
// package-name.version arch new-version
// Regex to parse dnf check-update output.
// Format: pkgname.arch version repo (three columns, verified on dnf5)
re := regexp.MustCompile(`^([^\s]+)\.([^\s]+)\s+([^\s]+)\s+([^\s]+)$`)
for scanner.Scan() {
@ -64,36 +67,26 @@ func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
matches := re.FindStringSubmatch(line)
if len(matches) < 5 {
continue
log.Printf("[WARNING] [scanner] [dnf] unparseable_line line=%q", line)
continue
}
packageName := matches[1]
arch := matches[2]
repoAndVersion := matches[3]
newVersion := matches[4]
version := matches[3]
repository := matches[4]
// Extract repository and current version from repoAndVersion
// Format is typically: repo-version current-version
parts := strings.Fields(repoAndVersion)
var repository, currentVersion string
if len(parts) >= 2 {
repository = parts[0]
currentVersion = parts[1]
} else if len(parts) == 1 {
repository = parts[0]
// Try to get current version from rpm
currentVersion = getInstalledVersion(packageName)
}
// Get currently installed version via rpm
currentVersion := getInstalledVersion(packageName)
// Determine severity based on repository and update type
severity := determineSeverity(repository, packageName, newVersion)
severity := determineSeverity(repository, packageName, version)
update := client.UpdateReportItem{
PackageType: "dnf",
PackageName: packageName,
CurrentVersion: currentVersion,
AvailableVersion: newVersion,
AvailableVersion: version,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{

View file

@ -0,0 +1,174 @@
package scanner
import (
"testing"
)
// Real `dnf check-update` output captured from dnf5 v5.2.18 on Fedora 43.
// Format: pkgname.arch version repo (three columns).
func TestParseDNFOutput_StandardRepos(t *testing.T) {
output := []byte("SDL3.x86_64 3.4.8-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.PackageName != "SDL3" {
t.Errorf("PackageName = %q, want %q", u.PackageName, "SDL3")
}
if u.AvailableVersion != "3.4.8-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "3.4.8-1.fc43")
}
if u.RepositorySource != "updates" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "updates")
}
if u.PackageType != "dnf" {
t.Errorf("PackageType = %q, want %q", u.PackageType, "dnf")
}
}
func TestParseDNFOutput_CoprRepo(t *testing.T) {
output := []byte("7zip.x86_64 26.01-1.fc43 copr:copr.fedorainfracloud.org:errornointernet:packages\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.PackageName != "7zip" {
t.Errorf("PackageName = %q, want %q", u.PackageName, "7zip")
}
if u.AvailableVersion != "26.01-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "26.01-1.fc43")
}
if u.RepositorySource != "copr:copr.fedorainfracloud.org:errornointernet:packages" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "copr:copr.fedorainfracloud.org:errornointernet:packages")
}
}
func TestParseDNFOutput_DockerRepo(t *testing.T) {
output := []byte("docker-ce.x86_64 3:29.5.2-1.fc43 docker-ce-stable\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.AvailableVersion != "3:29.5.2-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "3:29.5.2-1.fc43")
}
if u.RepositorySource != "docker-ce-stable" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "docker-ce-stable")
}
}
func TestParseDNFOutput_CoprRepoWithColons(t *testing.T) {
output := []byte("coolercontrol.x86_64 4.3.1-1.fc43 copr:copr.fedorainfracloud.org:codifryed:CoolerControl\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
if u.AvailableVersion != "4.3.1-1.fc43" {
t.Errorf("AvailableVersion = %q, want %q", u.AvailableVersion, "4.3.1-1.fc43")
}
if u.RepositorySource != "copr:copr.fedorainfracloud.org:codifryed:CoolerControl" {
t.Errorf("RepositorySource = %q, want %q", u.RepositorySource, "copr:copr.fedorainfracloud.org:codifryed:CoolerControl")
}
}
func TestParseDNFOutput_SkipsHeader(t *testing.T) {
output := []byte("Last metadata expiration check: 0:05:23 ago on Wed 2026-05-28.\n\nSDL3.x86_64 3.4.8-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update (skip header+blank), got %d", len(updates))
}
}
func TestParseDNFOutput_SkipsObsoleting(t *testing.T) {
output := []byte("Obsoleting Packages:\nSDL3.x86_64 3.4.8-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update (skip Obsoleting), got %d", len(updates))
}
}
func TestParseDNFOutput_Empty(t *testing.T) {
updates, err := parseDNFOutput([]byte{})
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 0 {
t.Fatalf("expected 0 updates from empty input, got %d", len(updates))
}
}
func TestParseDNFOutput_ArchitectureInMetadata(t *testing.T) {
output := []byte("curl.x86_64 8.15.0-7.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
u := updates[0]
arch, ok := u.Metadata["architecture"].(string)
if !ok {
t.Fatal("metadata.architecture missing or not a string")
}
if arch != "x86_64" {
t.Errorf("architecture = %q, want %q", arch, "x86_64")
}
}
func TestParseDNFOutput_MultipleLines(t *testing.T) {
output := []byte("curl.x86_64 8.15.0-7.fc43 updates\ndnsmasq.x86_64 2.92rel2-2.fc43 updates\nbind-libs.x86_64 32:9.18.49-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 3 {
t.Fatalf("expected 3 updates, got %d", len(updates))
}
names := []string{"curl", "dnsmasq", "bind-libs"}
for i, name := range names {
if updates[i].PackageName != name {
t.Errorf("updates[%d].PackageName = %q, want %q", i, updates[i].PackageName, name)
}
}
}
// CurrentVersion is populated via `rpm -q` which won't work in test
// environments. Verify it returns "unknown" rather than crashing.
func TestParseDNFOutput_CurrentVersionUnknown(t *testing.T) {
output := []byte("nonexistent-package-12345.x86_64 1.0-1.fc43 updates\n")
updates, err := parseDNFOutput(output)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(updates) != 1 {
t.Fatalf("expected 1 update, got %d", len(updates))
}
// getInstalledVersion fails for fake packages → returns "unknown"
if updates[0].CurrentVersion != "unknown" {
t.Errorf("CurrentVersion = %q, want %q", updates[0].CurrentVersion, "unknown")
}
}

View file

@ -1,162 +0,0 @@
package scanner
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
)
// DockerScanner scans for Docker image updates
type DockerScanner struct {
client *dockerclient.Client
registryClient *RegistryClient
}
// NewDockerScanner creates a new Docker scanner
func NewDockerScanner() (*DockerScanner, error) {
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &DockerScanner{
client: cli,
registryClient: NewRegistryClient(),
}, nil
}
// IsAvailable checks if Docker is available on this system
func (s *DockerScanner) IsAvailable() bool {
_, err := exec.LookPath("docker")
if err != nil {
return false
}
// Try to ping Docker daemon
if s.client != nil {
_, err := s.client.Ping(context.Background())
return err == nil
}
return false
}
// Scan scans for available Docker image updates
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
ctx := context.Background()
// List all containers
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var updates []client.UpdateReportItem
seenImages := make(map[string]bool)
for _, c := range containers {
imageName := c.Image
// Skip if we've already checked this image
if seenImages[imageName] {
continue
}
seenImages[imageName] = true
// Get current image details
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
if err != nil {
continue
}
// Parse image name and tag
parts := strings.Split(imageName, ":")
baseImage := parts[0]
currentTag := "latest"
if len(parts) > 1 {
currentTag = parts[1]
}
// Check if update is available by comparing with registry
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
if hasUpdate {
// Extract short digest for display (first 12 chars of sha256 hash)
localDigest := imageInspect.ID
remoteShortDigest := "unknown"
if len(remoteDigest) > 7 {
// Format: sha256:abcd... -> take first 12 chars of hash
parts := strings.SplitN(remoteDigest, ":", 2)
if len(parts) == 2 && len(parts[1]) >= 12 {
remoteShortDigest = parts[1][:12]
}
}
update := client.UpdateReportItem{
PackageType: "docker_image",
PackageName: imageName,
PackageDescription: fmt.Sprintf("Container: %s", strings.Join(c.Names, ", ")),
CurrentVersion: localDigest[:12], // Short hash
AvailableVersion: remoteShortDigest,
Severity: "moderate",
RepositorySource: baseImage,
Metadata: map[string]interface{}{
"container_id": c.ID[:12],
"container_names": c.Names,
"container_state": c.State,
"image_created": imageInspect.Created,
"local_full_digest": localDigest,
"remote_digest": remoteDigest,
},
}
updates = append(updates, update)
}
}
return updates, nil
}
// checkForUpdate checks if a newer image version is available by comparing digests
// Returns (hasUpdate bool, remoteDigest string)
//
// This implementation:
// 1. Queries Docker Registry HTTP API v2 for remote manifest
// 2. Compares image digests (sha256 hashes) between local and remote
// 3. Handles authentication for Docker Hub (anonymous pull)
// 4. Caches registry responses (5 min TTL) to respect rate limits
// 5. Returns both the update status and remote digest for metadata
//
// Note: This compares exact digests. If local digest != remote digest, an update exists.
// This works for all tags including "latest", version tags, etc.
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
// Get remote digest from registry
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
if err != nil {
// If we can't check the registry, log the error but don't report an update
// This prevents false positives when registry is down or rate-limited
fmt.Printf("Warning: Failed to check registry for %s:%s: %v\n", imageName, tag, err)
return false, ""
}
// Compare digests
// Local Docker image ID format: sha256:abc123...
// Remote digest format: sha256:def456...
// If they differ, an update is available
hasUpdate := currentID != remoteDigest
return hasUpdate, remoteDigest
}
// Close closes the Docker client
func (s *DockerScanner) Close() error {
if s.client != nil {
return s.client.Close()
}
return nil
}

View file

@ -1,259 +0,0 @@
package scanner
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
type RegistryClient struct {
httpClient *http.Client
cache *manifestCache
}
// manifestCache stores registry responses to avoid hitting rate limits
type manifestCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
digest string
expiresAt time.Time
}
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
type ManifestResponse struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config struct {
Digest string `json:"digest"`
} `json:"config"`
}
// DockerHubTokenResponse represents the authentication token response from Docker Hub
type DockerHubTokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
IssuedAt time.Time `json:"issued_at"`
}
// NewRegistryClient creates a new registry client with caching
func NewRegistryClient() *RegistryClient {
return &RegistryClient{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
cache: &manifestCache{
entries: make(map[string]*cacheEntry),
},
}
}
// GetRemoteDigest fetches the digest of a remote image from the registry
// Returns the digest string (e.g., "sha256:abc123...") or an error
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
// Parse image name to determine registry and repository
registry, repository := parseImageName(imageName)
// Check cache first
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
if digest := c.cache.get(cacheKey); digest != "" {
return digest, nil
}
// Get authentication token (if needed)
token, err := c.getAuthToken(ctx, registry, repository)
if err != nil {
return "", fmt.Errorf("failed to get auth token: %w", err)
}
// Fetch manifest from registry
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
if err != nil {
return "", fmt.Errorf("failed to fetch manifest: %w", err)
}
// Cache the result (5 minute TTL to avoid hammering registries)
c.cache.set(cacheKey, digest, 5*time.Minute)
return digest, nil
}
// parseImageName splits an image name into registry and repository
// Examples:
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
func parseImageName(imageName string) (registry, repository string) {
parts := strings.Split(imageName, "/")
// Check if first part looks like a domain (contains . or :)
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
// Custom registry: gcr.io/myproject/myimage
registry = parts[0]
repository = strings.Join(parts[1:], "/")
} else if len(parts) == 1 {
// Official image: nginx -> library/nginx
registry = "registry-1.docker.io"
repository = "library/" + parts[0]
} else {
// User image: myuser/myimage
registry = "registry-1.docker.io"
repository = imageName
}
return registry, repository
}
// getAuthToken obtains an authentication token for the registry
// For Docker Hub, uses the token authentication flow
// For other registries, may need different auth mechanisms (TODO: implement)
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
// Docker Hub token authentication
if registry == "registry-1.docker.io" {
return c.getDockerHubToken(ctx, repository)
}
// For other registries, we'll try unauthenticated first
// TODO: Support authentication for private registries (basic auth, bearer tokens, etc.)
return "", nil
}
// getDockerHubToken obtains a token from Docker Hub's authentication service
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
authURL := fmt.Sprintf(
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
repository,
)
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
if err != nil {
return "", err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp DockerHubTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
}
// Docker Hub can return either 'token' or 'access_token'
if tokenResp.Token != "" {
return tokenResp.Token, nil
}
return tokenResp.AccessToken, nil
}
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
// Build manifest URL
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
if err != nil {
return "", err
}
// Set required headers
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
}
if resp.StatusCode == http.StatusUnauthorized {
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
}
// Try to get digest from Docker-Content-Digest header first (faster)
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
return digest, nil
}
// Fallback: parse manifest and extract config digest
var manifest ManifestResponse
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return "", fmt.Errorf("failed to decode manifest: %w", err)
}
if manifest.Config.Digest == "" {
return "", fmt.Errorf("manifest does not contain a config digest")
}
return manifest.Config.Digest, nil
}
// manifestCache methods
func (mc *manifestCache) get(key string) string {
mc.mu.RLock()
defer mc.mu.RUnlock()
entry, exists := mc.entries[key]
if !exists {
return ""
}
if time.Now().After(entry.expiresAt) {
// Entry expired
delete(mc.entries, key)
return ""
}
return entry.digest
}
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.entries[key] = &cacheEntry{
digest: digest,
expiresAt: time.Now().Add(ttl),
}
}
// cleanupExpired removes expired entries from the cache (called periodically)
func (mc *manifestCache) cleanupExpired() {
mc.mu.Lock()
defer mc.mu.Unlock()
now := time.Now().UTC()
for key, entry := range mc.entries {
if now.After(entry.expiresAt) {
delete(mc.entries, key)
}
}
}

View file

@ -23,5 +23,7 @@ func (s *WindowsUpdateScanner) Scan() ([]client.UpdateReportItem, error) {
return []client.UpdateReportItem{}, nil
}
func (s *WindowsUpdateScanner) Name() string { return "Windows Update Scanner" }

View file

@ -23,6 +23,8 @@ func NewWindowsUpdateScannerWUA() *WindowsUpdateScannerWUA {
return &WindowsUpdateScannerWUA{}
}
func (s *WindowsUpdateScannerWUA) Name() string { return "Windows Update Scanner" }
// IsAvailable checks if WUA scanner is available on this system
func (s *WindowsUpdateScannerWUA) IsAvailable() bool {
// Only available on Windows

View file

@ -33,6 +33,8 @@ func NewWingetScanner() *WingetScanner {
return &WingetScanner{}
}
func (s *WingetScanner) Name() string { return "Winget Package Update Scanner" }
// IsAvailable checks if winget is available on this system
func (s *WingetScanner) IsAvailable() bool {
if runtime.GOOS != "windows" {

View file

@ -21,7 +21,7 @@ services:
context: .
dockerfile: ./server/Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.5}
BUILD_VERSION: ${BUILD_VERSION:-0.2.0.6}
container_name: redflag-server
volumes:
- server-config:/app/config

View file

@ -38,6 +38,16 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build \
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
-o binaries/linux-arm64/redflag-agent ./cmd/agent
# Build for macOS amd64
RUN CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build \
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
-o binaries/darwin-amd64/redflag-agent ./cmd/agent
# Build for macOS arm64
RUN CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build \
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \
-o binaries/darwin-arm64/redflag-agent ./cmd/agent
# Build for Windows amd64
RUN CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build \
-ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$BUILD_VERSION" \

View file

@ -204,8 +204,8 @@ func main() {
upstreamQueries := queries.NewUpstreamQueries(db.DB)
agentTrackedSoftwareQueries := queries.NewAgentTrackedSoftwareQueries(db.DB)
agentUpdateQueries := queries.NewAgentUpdateQueries(db.DB)
metricsQueries := queries.NewMetricsQueries(db.DB.DB)
dockerQueries := queries.NewDockerQueries(db.DB.DB)
metricsQueries := queries.NewMetricsQueries(db.DB)
dockerQueries := queries.NewDockerQueries(db.DB)
storageMetricsQueries := queries.NewStorageMetricsQueries(db.DB.DB)
adminQueries := queries.NewAdminQueries(db.DB)
@ -257,7 +257,7 @@ func main() {
buildOrchestrator = services.NewBuildOrchestratorService(signingService, packageQueries, filepath.Join("/app"))
log.Printf("[system] BuildOrchestratorService initialized - will sign agent binaries")
// Sign all pre-built binaries at startup
platforms := []string{"linux-amd64", "linux-arm64", "windows-amd64", "windows-arm64"}
platforms := []string{"linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64", "windows-arm64"}
for _, platform := range platforms {
parts := strings.SplitN(platform, "-", 2)
if len(parts) == 2 {
@ -474,8 +474,12 @@ func main() {
// Package artifact download (for hash computation at approval time)
api.GET("/downloads/artifact", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadPackageArtifact)
// Protected download routes (F-A3-6, F-A3-7: require authentication)
api.GET("/downloads/updates/:package_id", middleware.AuthMiddleware(), rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadUpdatePackage)
// Protected download routes (F-A3-6, F-A3-7: require authentication + machine binding)
api.GET("/downloads/updates/:package_id",
middleware.AuthMiddleware(),
middleware.MachineBindingMiddleware(agentQueries, cfg.MinAgentVersion),
rateLimiter.RateLimit("public_access", middleware.KeyByIP),
downloadHandler.DownloadUpdatePackage)
api.GET("/downloads/config/:agent_id", authHandler.WebAuthMiddleware(), rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.HandleConfigDownload)
}
@ -519,6 +523,9 @@ func main() {
// Initialize and start scheduler
schedulerConfig := scheduler.DefaultConfig()
subsystemScheduler := scheduler.NewScheduler(schedulerConfig, agentQueries, commandQueries, subsystemQueries, signingService)
// Wire scheduler into SubsystemHandler so DisableSubsystem can evict
// jobs from the in-memory priority queue.
subsystemHandler.SetScheduler(subsystemScheduler)
// Initialize agentHandler now that scheduler is available
agentHandler := handlers.NewAgentHandler(agentQueries, commandQueries, refreshTokenQueries, registrationTokenQueries, subsystemQueries, subsystemScheduler, signingService, securityLogger, cfg.CheckInInterval, cfg.LatestAgentVersion, stuckCommandTimeout, maxCommandRetries)
@ -539,7 +546,7 @@ func main() {
}
// Initialize updateHandler with the agentHandler reference
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler, maintenanceWindowQueries)
updateHandler := handlers.NewUpdateHandler(updateQueries, agentQueries, commandQueries, agentHandler, maintenanceWindowQueries, cfg)
if securitySettingsService != nil {
// Wires policy.allow_dry_runs.
updateHandler.SetSecuritySettings(securitySettingsService)

View file

@ -28,6 +28,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/doug-martin/goqu/v9 v9.19.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect

View file

@ -4,6 +4,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@ -34,6 +35,7 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v25.0.6+incompatible h1:5cPwbwriIcsua2REJe8HqQV+6WlWc1byg2QSXzBxBGg=
@ -42,6 +44,8 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/doug-martin/goqu/v9 v9.19.0 h1:PD7t1X3tRcUiSdc5TEyOFKujZA5gs3VSA7wxSvBx7qo=
github.com/doug-martin/goqu/v9 v9.19.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@ -77,6 +81,7 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@ -87,6 +92,7 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -117,10 +123,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
@ -164,6 +172,7 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@ -203,6 +212,8 @@ go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=

View file

@ -757,8 +757,10 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
// which now only sees 'pending' and 'sent'. Receipt confirmation goes back in the
// response so the agent can drop them from its outbox.
var receiptConfirmedIDs []string
var confirmed []string // commands the server has marked completed (via ReportLog)
if len(metrics.ReceivedCommandIDs) > 0 {
confirmed, markErr := h.commandQueries.MarkCommandsReceivedTx(cmdTx, agentID, metrics.ReceivedCommandIDs)
var markErr error
confirmed, markErr = h.commandQueries.MarkCommandsReceivedTx(cmdTx, agentID, metrics.ReceivedCommandIDs)
if markErr != nil {
log.Printf("[ERROR] [server] [command] mark_received_failed agent_id=%s reported=%d error=%v",
agentID, len(metrics.ReceivedCommandIDs), markErr)
@ -769,6 +771,9 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
agentID, len(confirmed), len(metrics.ReceivedCommandIDs))
}
}
} else {
// No ReceivedCommandIDs — start with empty confirmed set
confirmed = []string{}
}
// Get pending commands with row-level lock
@ -957,117 +962,17 @@ func (h *AgentHandler) GetCommands(c *gin.Context) {
}
}
// Hybrid Heartbeat: Check for scheduled subsystem jobs during heartbeat mode
// This ensures that even in heartbeat mode, scheduled scans can be triggered
if h.scheduler != nil {
// Only check for scheduled jobs if agent is in heartbeat mode (rapid polling enabled)
isHeartbeatMode := rapidPolling != nil && rapidPolling.Enabled
if isHeartbeatMode {
if err := h.checkAndCreateScheduledCommands(agentID); err != nil {
// Log error but don't fail the request - this is enhancement, not core functionality
log.Printf("[Heartbeat] Failed to check scheduled commands for agent %s: %v", agentID, err)
}
}
}
response := models.CommandsResponse{
Commands: commandItems,
RapidPolling: rapidPolling,
AcknowledgedIDs: acknowledgedIDs,
ReceiptConfirmedIDs: receiptConfirmedIDs,
Commands: commandItems,
RapidPolling: rapidPolling,
AcknowledgedIDs: acknowledgedIDs,
ReceiptConfirmedIDs: receiptConfirmedIDs,
ConfirmedCommandIDs: confirmed,
}
c.JSON(http.StatusOK, response)
}
// checkAndCreateScheduledCommands checks if any subsystem jobs are due for the agent
// and creates commands for them using the scheduler (following Option A approach)
func (h *AgentHandler) checkAndCreateScheduledCommands(agentID uuid.UUID) error {
// Get current subsystems for this agent from database
subsystems, err := h.subsystemQueries.GetSubsystems(agentID)
if err != nil {
return fmt.Errorf("failed to get subsystems: %w", err)
}
// Check each enabled subsystem with auto_run=true
now := time.Now().UTC()
jobsCreated := 0
for _, subsystem := range subsystems {
if !subsystem.Enabled || !subsystem.AutoRun {
continue
}
// Check if this subsystem job is due
var isDue bool
if subsystem.NextRunAt == nil {
// No next run time set, it's due
isDue = true
} else {
// Check if next run time has passed
isDue = subsystem.NextRunAt.Before(now) || subsystem.NextRunAt.Equal(now)
}
if isDue {
// Create the command using scheduler logic (reusing existing safeguards)
if err := h.createSubsystemCommand(agentID, subsystem); err != nil {
log.Printf("[Heartbeat] Failed to create command for %s subsystem: %v", subsystem.Subsystem, err)
continue
}
jobsCreated++
// Update next run time in database ONLY after successful command creation
if err := h.updateNextRunTime(agentID, subsystem); err != nil {
log.Printf("[Heartbeat] Failed to update next run time for %s subsystem: %v", subsystem.Subsystem, err)
}
}
}
if jobsCreated > 0 {
log.Printf("[Heartbeat] Created %d scheduled commands for agent %s", jobsCreated, agentID)
}
return nil
}
// createSubsystemCommand creates a subsystem scan command using scheduler's logic
func (h *AgentHandler) createSubsystemCommand(agentID uuid.UUID, subsystem models.AgentSubsystem) error {
// Check backpressure: skip if agent has too many pending commands
pendingCount, err := h.commandQueries.CountPendingCommandsForAgent(agentID)
if err != nil {
return fmt.Errorf("failed to check pending commands: %w", err)
}
// Backpressure threshold (same as scheduler)
const backpressureThreshold = 10
if pendingCount >= backpressureThreshold {
return fmt.Errorf("agent has %d pending commands (threshold: %d), skipping", pendingCount, backpressureThreshold)
}
// Create the command using same format as scheduler
cmd := &models.AgentCommand{
ID: uuid.New(),
AgentID: agentID,
CommandType: fmt.Sprintf("scan_%s", subsystem.Subsystem),
Params: models.JSONB{},
Status: models.CommandStatusPending,
Source: models.CommandSourceSystem,
CreatedAt: time.Now().UTC(),
}
if err := h.signAndCreateCommand(cmd); err != nil {
return fmt.Errorf("failed to create command: %w", err)
}
return nil
}
// updateNextRunTime updates the last_run_at and next_run_at for a subsystem after creating a command
func (h *AgentHandler) updateNextRunTime(agentID uuid.UUID, subsystem models.AgentSubsystem) error {
// Use the existing UpdateLastRun method which handles next_run_at calculation
return h.subsystemQueries.UpdateLastRun(agentID, subsystem.Subsystem)
}
// ListAgents returns all agents with last scan information
func (h *AgentHandler) ListAgents(c *gin.Context) {
status := c.Query("status")

View file

@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/doug-martin/goqu/v9"
)
// ClientErrorHandler handles frontend error logging per ETHOS #1
@ -43,6 +44,7 @@ type ClientErrorResponse struct {
CreatedAt time.Time `json:"created_at"`
}
// GetErrors returns paginated error logs (admin only)
// GetErrors returns paginated error logs (admin only)
func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
// Parse pagination params
@ -63,67 +65,61 @@ func (h *ClientErrorHandler) GetErrors(c *gin.Context) {
errorType := c.Query("error_type")
agentIDStr := c.Query("agent_id")
// Build query
query := `SELECT id, agent_id, subsystem, error_type, message, metadata, url, created_at
FROM client_errors
WHERE 1=1`
params := map[string]interface{}{}
// Build filter with goqu
sd := goqu.Dialect("postgres").From("client_errors")
if subsystem != "" {
query += " AND subsystem = :subsystem"
params["subsystem"] = subsystem
sd = sd.Where(goqu.Ex{"subsystem": subsystem})
}
if errorType != "" {
query += " AND error_type = :error_type"
params["error_type"] = errorType
sd = sd.Where(goqu.Ex{"error_type": errorType})
}
if agentIDStr != "" {
query += " AND agent_id = :agent_id"
params["agent_id"] = agentIDStr
if id, err := uuid.Parse(agentIDStr); err == nil {
sd = sd.Where(goqu.Ex{"agent_id": id})
}
}
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
params["limit"] = pageSize
params["offset"] = (page - 1) * pageSize
// Count
countSQL, countArgs, err := sd.Select(goqu.COUNT("*")).ToSQL()
if err != nil {
log.Printf(`[ERROR] [server] [client_error] count_build_failed error="%v"`, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
var total int64
if err := h.db.Get(&total, countSQL, countArgs...); err != nil {
log.Printf(`[ERROR] [server] [client_error] count_failed error="%v"`, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
return
}
// Execute query
var errors []ClientErrorResponse
if err := h.db.Select(&errors, query, params); err != nil {
log.Printf("[ERROR] [server] [client_error] query_failed error=\"%v\"", err)
// Data with pagination
sql, args, err := sd.Select(
"id", "agent_id", "subsystem", "error_type", "message", "metadata", "url", "created_at",
).Order(goqu.C("created_at").Desc()).Limit(uint(pageSize)).Offset(uint((page - 1) * pageSize)).ToSQL()
if err != nil {
log.Printf(`[ERROR] [server] [client_error] query_build_failed error="%v"`, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// Get total count
countQuery := `SELECT COUNT(*) FROM client_errors WHERE 1=1`
if subsystem != "" {
countQuery += " AND subsystem = :subsystem"
}
if errorType != "" {
countQuery += " AND error_type = :error_type"
}
if agentIDStr != "" {
countQuery += " AND agent_id = :agent_id"
}
var total int64
if err := h.db.Get(&total, countQuery, params); err != nil {
log.Printf("[ERROR] [server] [client_error] count_failed error=\"%v\"", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "count failed"})
var errors []ClientErrorResponse
if err := h.db.Select(&errors, sql, args...); err != nil {
log.Printf(`[ERROR] [server] [client_error] query_failed error="%v"`, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
response := GetErrorsResponse{
c.JSON(http.StatusOK, GetErrorsResponse{
Errors: errors,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}
c.JSON(http.StatusOK, response)
})
}
// LogErrorRequest represents a client error log entry

View file

@ -425,7 +425,7 @@ func (h *DockerHandler) RejectUpdate(c *gin.Context) {
}
// For now, we'll mark as rejected (this would need a proper reject method in queries)
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "rejected", nil, nil); err != nil {
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "ignored", nil, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject Docker update"})
return
}

View file

@ -10,6 +10,7 @@ import (
"github.com/Fimeg/RedFlag/server/internal/command"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/scheduler"
"github.com/Fimeg/RedFlag/server/internal/services"
"github.com/Fimeg/RedFlag/server/internal/logging"
"github.com/gin-gonic/gin"
@ -23,6 +24,7 @@ type SubsystemHandler struct {
commandFactory *command.Factory
signingService *services.SigningService
securityLogger *logging.SecurityLogger
scheduler *scheduler.Scheduler
}
func NewSubsystemHandler(sq *queries.SubsystemQueries, cq *queries.CommandQueries, aq *queries.AgentQueries, cf *command.Factory, signingService *services.SigningService, securityLogger *logging.SecurityLogger) *SubsystemHandler {
@ -36,6 +38,12 @@ func NewSubsystemHandler(sq *queries.SubsystemQueries, cq *queries.CommandQuerie
}
}
// SetScheduler injects the scheduler reference so DisableSubsystem can evict
// jobs from the in-memory queue. Nil-safe — a nil scheduler is a no-op.
func (h *SubsystemHandler) SetScheduler(s *scheduler.Scheduler) {
h.scheduler = s
}
// signAndCreateCommand signs a command before storing.
// STRICT MODE: Commands without signatures are rejected (ETHOS #2 Security is Non-Negotiable)
func (h *SubsystemHandler) signAndCreateCommand(cmd *models.AgentCommand) error {
@ -226,6 +234,16 @@ func (h *SubsystemHandler) DisableSubsystem(c *gin.Context) {
return
}
// Evict the scheduled job from the in-memory scheduler so it stops generating
// commands immediately — the DB flip alone isn't enough since the scheduler's
// priority queue only re-reads state on restart.
if h.scheduler != nil {
if removed := h.scheduler.RemoveSubsystemJob(agentID, subsystem); removed {
log.Printf("[INFO] [server] [subsystems] scheduler_job_removed agent_id=%s subsystem=%s",
agentID, subsystem)
}
}
c.JSON(http.StatusOK, gin.H{"message": "Subsystem disabled successfully"})
}

View file

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/Fimeg/RedFlag/server/internal/services"
@ -40,15 +41,17 @@ type UpdateHandler struct {
agentHandler *AgentHandler
maintenanceWindowQueries *queries.MaintenanceWindowQueries
securitySettings *services.SecuritySettingsService // optional; reads policy.allow_dry_runs
config *config.Config // optional; for self-referential URLs
}
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler, mwq *queries.MaintenanceWindowQueries) *UpdateHandler {
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler, mwq *queries.MaintenanceWindowQueries, cfg *config.Config) *UpdateHandler {
return &UpdateHandler{
updateQueries: uq,
agentQueries: aq,
commandQueries: cq,
agentHandler: ah,
maintenanceWindowQueries: mwq,
config: cfg,
}
}
@ -323,9 +326,12 @@ func (h *UpdateHandler) computeAndStorePackageHash(update *models.UpdateState) (
ecosystem := services.EcosystemFromPackageType(update.PackageType)
// Call the download handler's artifact endpoint using server's public URL
// Call the download handler's artifact endpoint using the server's own URL
// This ensures the hash is computed from the same source agents will use
baseURL := "http://localhost:8080"
baseURL := h.config.Server.PublicURL
if baseURL == "" {
baseURL = fmt.Sprintf("http://%s:%d", h.config.Server.Host, h.config.Server.Port)
}
downloadURL := fmt.Sprintf("%s/api/v1/downloads/artifact?ecosystem=%s&package_name=%s&version=%s",
baseURL, ecosystem, update.PackageName, update.AvailableVersion)
@ -379,6 +385,28 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
return
}
// Idempotency check: if command_id is provided and command is already
// completed/failed, reject duplicate submissions (ETHOS #4).
if req.CommandID != "" {
commandID, err := uuid.Parse(req.CommandID)
if err == nil {
command, err := h.commandQueries.GetCommandByID(commandID)
if err == nil && command != nil {
// Command already has a terminal status — reject duplicate
if command.Status == models.CommandStatusCompleted || command.Status == models.CommandStatusFailed || command.Status == models.CommandStatusTimedOut {
log.Printf("[INFO] [server] [updates] duplicate_log_rejected agent_id=%s command_id=%s status=%s",
agentID, commandID, command.Status)
c.JSON(http.StatusConflict, gin.H{
"error": "duplicate log submission",
"command_id": commandID.String(),
"current_status": command.Status,
})
return
}
}
}
}
// Validate and map result to comply with database constraint
validResult := req.Result
if !isValidResult(validResult) {
@ -478,6 +506,20 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
log.Printf("[ERROR] [server] [updates] mark_failed_failed agent_id=%s command_id=%s error=%q",
agentID, commandID, markErr)
}
// If this was a failed confirm_dependencies command, mark the package as failed
command, err := h.commandQueries.GetCommandByID(commandID)
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
if packageName, ok := command.Params["package_name"].(string); ok {
if packageType, ok := command.Params["package_type"].(string); ok {
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "failed", nil, nil); err != nil {
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
} else {
log.Printf("[INFO] [server] [updates] package_failed package=%s type=%s", packageName, packageType)
}
}
}
}
} else {
// For other results, just update the result field
if markErr = h.commandQueries.UpdateCommandResult(commandID, result); markErr != nil {

View file

@ -8,6 +8,8 @@ import (
"path/filepath"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/server/internal/version"
)
// Config holds the application configuration
@ -161,7 +163,7 @@ func loadFromEnv(cfg *Config, skipSensitive bool) error {
cfg.CheckInInterval = checkInInterval
cfg.OfflineThreshold = offlineThreshold
cfg.Timezone = getEnv("TIMEZONE", "UTC")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.2.0.2")
cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", version.AgentVersion)
cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.26")
cfg.BinaryStoragePath = getEnv("REDFLAG_BINARY_STORAGE_PATH", "./binaries")

View file

@ -0,0 +1,4 @@
-- Restore old constraint vocabulary: ('success', 'failed', 'rollback')
ALTER TABLE update_version_history DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
ALTER TABLE update_version_history ADD CONSTRAINT update_version_history_update_status_check
CHECK (update_status IN ('success', 'failed', 'rollback'));

View file

@ -0,0 +1,9 @@
-- Align update_version_history.update_status vocabulary with
-- current_package_state.status: 'updated' instead of 'success'.
-- The old constraint allowed ('success', 'failed', 'rollback').
-- The new constraint allows ('updated', 'failed', 'rollback').
-- No data migration is needed — the existing rows are timestamped
-- audit records, and the vocabulary change only affects future inserts.
ALTER TABLE update_version_history DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
ALTER TABLE update_version_history ADD CONSTRAINT update_version_history_update_status_check
CHECK (update_status IN ('updated', 'failed', 'rollback'));

View file

@ -7,6 +7,7 @@ import (
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/doug-martin/goqu/v9"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@ -96,23 +97,20 @@ func (q *AgentQueries) UpdateAgentMetadata(id uuid.UUID, metadata models.JSONB,
// ListAgents returns all agents with optional filtering
func (q *AgentQueries) ListAgents(status, osType string) ([]models.Agent, error) {
var agents []models.Agent
query := `SELECT * FROM agents WHERE 1=1`
args := []interface{}{}
argIdx := 1
sd := PG().From("agents")
if status != "" {
query += ` AND status = $` + string(rune(argIdx+'0'))
args = append(args, status)
argIdx++
sd = sd.Where(goqu.Ex{"status": status})
}
if osType != "" {
query += ` AND os_type = $` + string(rune(argIdx+'0'))
args = append(args, osType)
argIdx++
sd = sd.Where(goqu.Ex{"os_type": osType})
}
query += ` ORDER BY last_seen DESC`
err := q.db.Select(&agents, query, args...)
sql, args, err := sd.Select("*").Order(goqu.C("last_seen").Desc()).ToSQL()
if err != nil {
return nil, err
}
err = q.db.Select(&agents, sql, args...)
return agents, err
}

View file

@ -1,20 +1,21 @@
package queries
import (
"database/sql"
"fmt"
"log"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/doug-martin/goqu/v9"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// DockerQueries handles database operations for Docker images
type DockerQueries struct {
db *sql.DB
db *sqlx.DB
}
func NewDockerQueries(db *sql.DB) *DockerQueries {
func NewDockerQueries(db *sqlx.DB) *DockerQueries {
return &DockerQueries{db: db}
}
@ -70,147 +71,52 @@ func (q *DockerQueries) CreateDockerEventsBatch(events []models.StoredDockerImag
// GetDockerImages retrieves Docker images based on filter criteria
func (q *DockerQueries) GetDockerImages(filter *models.DockerFilter) (*models.DockerResult, error) {
query := `
SELECT id, agent_id, package_type, package_name, current_version, available_version,
severity, repository_source, metadata, event_type, created_at
FROM docker_images
WHERE 1=1
`
args := []interface{}{}
argIndex := 1
var images []models.StoredDockerImage
sd := PG().From("docker_images")
// Build WHERE clause
if filter.AgentID != nil {
query += fmt.Sprintf(" AND agent_id = $%d", argIndex)
args = append(args, *filter.AgentID)
argIndex++
sd = sd.Where(goqu.Ex{"agent_id": *filter.AgentID})
}
if filter.ImageName != nil {
query += fmt.Sprintf(" AND package_name ILIKE $%d", argIndex)
args = append(args, "%"+*filter.ImageName+"%")
argIndex++
sd = sd.Where(goqu.C("package_name").ILike("%" + *filter.ImageName + "%"))
}
if filter.Registry != nil {
query += fmt.Sprintf(" AND repository_source ILIKE $%d", argIndex)
args = append(args, "%"+*filter.Registry+"%")
argIndex++
sd = sd.Where(goqu.C("repository_source").ILike("%" + *filter.Registry + "%"))
}
if filter.Severity != nil {
query += fmt.Sprintf(" AND severity = $%d", argIndex)
args = append(args, *filter.Severity)
argIndex++
sd = sd.Where(goqu.Ex{"severity": *filter.Severity})
}
if filter.HasUpdates != nil {
if *filter.HasUpdates {
query += " AND current_version != available_version"
sd = sd.Where(goqu.C("current_version").Neq(goqu.C("available_version")))
} else {
query += " AND current_version = available_version"
sd = sd.Where(goqu.C("current_version").Eq(goqu.C("available_version")))
}
}
// Add ordering and pagination
query += " ORDER BY created_at DESC"
page := uint(1)
pageSize := uint(50)
if filter.Limit != nil {
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, *filter.Limit)
argIndex++
pageSize = uint(*filter.Limit)
if filter.Offset != nil {
page = uint(*filter.Offset / *filter.Limit) + 1
}
}
if filter.Offset != nil {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, *filter.Offset)
argIndex++
}
cols := []string{"id", "agent_id", "package_type", "package_name", "current_version",
"available_version", "severity", "repository_source", "metadata", "event_type", "created_at"}
rows, err := q.db.Query(query, args...)
total, err := Paginated(q.db, sd, page, pageSize, goqu.C("created_at").Desc(), cols, &images)
if err != nil {
return nil, fmt.Errorf("failed to query docker images: %w", err)
}
defer rows.Close()
var images []models.StoredDockerImage
for rows.Next() {
var image models.StoredDockerImage
err := rows.Scan(
&image.ID,
&image.AgentID,
&image.PackageType,
&image.PackageName,
&image.CurrentVersion,
&image.AvailableVersion,
&image.Severity,
&image.RepositorySource,
&image.Metadata,
&image.EventType,
&image.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan docker image: %w", err)
}
images = append(images, image)
}
// Get total count
countQuery := `SELECT COUNT(*) FROM docker_images WHERE 1=1`
countArgs := []interface{}{}
countIndex := 1
if filter.AgentID != nil {
countQuery += fmt.Sprintf(" AND agent_id = $%d", countIndex)
countArgs = append(countArgs, *filter.AgentID)
countIndex++
}
if filter.ImageName != nil {
countQuery += fmt.Sprintf(" AND package_name ILIKE $%d", countIndex)
countArgs = append(countArgs, "%"+*filter.ImageName+"%")
countIndex++
}
if filter.Registry != nil {
countQuery += fmt.Sprintf(" AND repository_source ILIKE $%d", countIndex)
countArgs = append(countArgs, "%"+*filter.Registry+"%")
countIndex++
}
if filter.Severity != nil {
countQuery += fmt.Sprintf(" AND severity = $%d", countIndex)
countArgs = append(countArgs, *filter.Severity)
countIndex++
}
if filter.HasUpdates != nil {
if *filter.HasUpdates {
countQuery += " AND current_version != available_version"
} else {
countQuery += " AND current_version = available_version"
}
}
var total int
err = q.db.QueryRow(countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, fmt.Errorf("failed to count docker images: %w", err)
}
// Calculate pagination
page := 1
perPage := 50
if filter.Offset != nil && filter.Limit != nil {
page = (*filter.Offset / *filter.Limit) + 1
perPage = *filter.Limit
}
return &models.DockerResult{
Images: images,
Total: total,
Page: page,
PerPage: perPage,
Page: int(page),
PerPage: int(pageSize),
}, nil
}

View file

@ -0,0 +1,47 @@
package queries
import (
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
_ "github.com/doug-martin/goqu/v9/dialect/postgres"
"github.com/jmoiron/sqlx"
)
func init() {
goqu.SetDefaultPrepared(true) // always use $1, $2 style placeholders
}
// PG returns a postgres dialect for building goqu queries.
func PG() goqu.DialectWrapper {
return goqu.Dialect("postgres")
}
// Paginated runs SELECT COUNT(*) and a paginated SELECT from the same base dataset.
// base should have From + Where applied. goqu datasets are immutable so the call
// does not mutate your builder.
// Returns total count and fills dest via sqlx.Select.
func Paginated(db sqlx.Queryer, base *goqu.SelectDataset, page, pageSize uint, order exp.OrderedExpression, columns []string, dest interface{}) (int, error) {
var total int
countSQL, countArgs, err := base.Select(goqu.COUNT("*")).ToSQL()
if err != nil {
return 0, err
}
if err := sqlx.Get(db, &total, countSQL, countArgs...); err != nil {
return 0, err
}
cols := make([]interface{}, len(columns))
for i, c := range columns {
cols[i] = c
}
dataSQL, dataArgs, err := base.Select(cols...).Order(order).Limit(pageSize).Offset((page-1)*pageSize).ToSQL()
if err != nil {
return 0, err
}
if err := sqlx.Select(db, dest, dataSQL, dataArgs...); err != nil {
return 0, err
}
return total, nil
}

View file

@ -6,15 +6,17 @@ import (
"log"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/doug-martin/goqu/v9"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// MetricsQueries handles database operations for metrics
type MetricsQueries struct {
db *sql.DB
db *sqlx.DB
}
func NewMetricsQueries(db *sql.DB) *MetricsQueries {
func NewMetricsQueries(db *sqlx.DB) *MetricsQueries {
return &MetricsQueries{db: db}
}
@ -70,119 +72,42 @@ func (q *MetricsQueries) CreateMetricsEventsBatch(events []models.StoredMetric)
// GetMetrics retrieves metrics based on filter criteria
func (q *MetricsQueries) GetMetrics(filter *models.MetricFilter) (*models.MetricResult, error) {
query := `
SELECT id, agent_id, package_type, package_name, current_version, available_version,
severity, repository_source, metadata, event_type, created_at
FROM metrics
WHERE 1=1
`
args := []interface{}{}
argIndex := 1
var metrics []models.StoredMetric
sd := PG().From("metrics")
// Build WHERE clause
if filter.AgentID != nil {
query += fmt.Sprintf(" AND agent_id = $%d", argIndex)
args = append(args, *filter.AgentID)
argIndex++
sd = sd.Where(goqu.Ex{"agent_id": *filter.AgentID})
}
if filter.PackageType != nil {
query += fmt.Sprintf(" AND package_type = $%d", argIndex)
args = append(args, *filter.PackageType)
argIndex++
sd = sd.Where(goqu.Ex{"package_type": *filter.PackageType})
}
if filter.Severity != nil {
query += fmt.Sprintf(" AND severity = $%d", argIndex)
args = append(args, *filter.Severity)
argIndex++
sd = sd.Where(goqu.Ex{"severity": *filter.Severity})
}
// Add ordering and pagination
query += " ORDER BY created_at DESC"
page := uint(1)
pageSize := uint(50)
if filter.Limit != nil {
query += fmt.Sprintf(" LIMIT $%d", argIndex)
args = append(args, *filter.Limit)
argIndex++
pageSize = uint(*filter.Limit)
if filter.Offset != nil {
page = uint(*filter.Offset / *filter.Limit) + 1
}
}
if filter.Offset != nil {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, *filter.Offset)
argIndex++
}
cols := []string{"id", "agent_id", "package_type", "package_name", "current_version",
"available_version", "severity", "repository_source", "metadata", "event_type", "created_at"}
rows, err := q.db.Query(query, args...)
total, err := Paginated(q.db, sd, page, pageSize, goqu.C("created_at").Desc(), cols, &metrics)
if err != nil {
return nil, fmt.Errorf("failed to query metrics: %w", err)
}
defer rows.Close()
var metrics []models.StoredMetric
for rows.Next() {
var metric models.StoredMetric
err := rows.Scan(
&metric.ID,
&metric.AgentID,
&metric.PackageType,
&metric.PackageName,
&metric.CurrentVersion,
&metric.AvailableVersion,
&metric.Severity,
&metric.RepositorySource,
&metric.Metadata,
&metric.EventType,
&metric.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan metric: %w", err)
}
metrics = append(metrics, metric)
}
// Get total count
countQuery := `SELECT COUNT(*) FROM metrics WHERE 1=1`
countArgs := []interface{}{}
countIndex := 1
if filter.AgentID != nil {
countQuery += fmt.Sprintf(" AND agent_id = $%d", countIndex)
countArgs = append(countArgs, *filter.AgentID)
countIndex++
}
if filter.PackageType != nil {
countQuery += fmt.Sprintf(" AND package_type = $%d", countIndex)
countArgs = append(countArgs, *filter.PackageType)
countIndex++
}
if filter.Severity != nil {
countQuery += fmt.Sprintf(" AND severity = $%d", countIndex)
countArgs = append(countArgs, *filter.Severity)
countIndex++
}
var total int
err = q.db.QueryRow(countQuery, countArgs...).Scan(&total)
if err != nil {
return nil, fmt.Errorf("failed to count metrics: %w", err)
}
// Calculate pagination
page := 1
perPage := 50
if filter.Offset != nil && filter.Limit != nil {
page = (*filter.Offset / *filter.Limit) + 1
perPage = *filter.Limit
}
return &models.MetricResult{
Metrics: metrics,
Total: total,
Page: page,
PerPage: perPage,
Page: int(page),
PerPage: int(pageSize),
}, nil
}

View file

@ -1,6 +1,7 @@
package queries
import (
"database/sql"
"encoding/json"
"fmt"
"log"
@ -8,6 +9,7 @@ import (
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/doug-martin/goqu/v9"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
@ -119,10 +121,16 @@ func (q *UpdateQueries) GetUpdateByID(id uuid.UUID) (*models.UpdateState, error)
// GetExpectedSHA256 retrieves the expected SHA256 hash for an update by ID
func (q *UpdateQueries) GetExpectedSHA256(id uuid.UUID) (string, error) {
var sha string
var sha sql.NullString
query := `SELECT expected_sha256 FROM current_package_state WHERE id = $1`
err := q.db.Get(&sha, query, id)
return sha, err
if err != nil {
return "", err
}
if !sha.Valid {
return "", nil
}
return sha.String, nil
}
// StoreExpectedSHA256 stores the expected SHA256 hash for an update
@ -459,75 +467,40 @@ func (q *UpdateQueries) updateCurrentStateInTx(tx *sqlx.Tx, event *models.Update
// ListUpdatesFromState returns paginated updates from current state with filtering
func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]models.UpdateState, int, error) {
var updates []models.UpdateState
var count int
// Build base query
baseQuery := `
SELECT
id, agent_id, package_type, package_name, current_version,
available_version, severity, repository_source, metadata,
last_discovered_at, last_updated_at, status
FROM current_package_state
WHERE 1=1
`
countQuery := `SELECT COUNT(*) FROM current_package_state WHERE 1=1`
sd := PG().From("current_package_state")
args := []interface{}{}
argIdx := 1
// Add filters
if filters.AgentID != uuid.Nil {
baseQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx)
countQuery += fmt.Sprintf(" AND agent_id = $%d", argIdx)
args = append(args, filters.AgentID)
argIdx++
sd = sd.Where(goqu.Ex{"agent_id": filters.AgentID})
}
if filters.PackageType != "" {
baseQuery += fmt.Sprintf(" AND package_type = $%d", argIdx)
countQuery += fmt.Sprintf(" AND package_type = $%d", argIdx)
args = append(args, filters.PackageType)
argIdx++
sd = sd.Where(goqu.Ex{"package_type": filters.PackageType})
}
if filters.Severity != "" {
baseQuery += fmt.Sprintf(" AND severity = $%d", argIdx)
countQuery += fmt.Sprintf(" AND severity = $%d", argIdx)
args = append(args, filters.Severity)
argIdx++
sd = sd.Where(goqu.Ex{"severity": filters.Severity})
}
if filters.Status != "" {
// Explicit status filter provided - use it
baseQuery += fmt.Sprintf(" AND status = $%d", argIdx)
countQuery += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, filters.Status)
argIdx++
statuses := strings.Split(filters.Status, ",")
if len(statuses) == 1 {
sd = sd.Where(goqu.Ex{"status": statuses[0]})
} else {
sd = sd.Where(goqu.Ex{"status": statuses})
}
} else {
// No status filter - exclude 'updated' and 'ignored' packages by default
// These should only be visible in history or when explicitly filtered
baseQuery += " AND status NOT IN ('updated', 'ignored')"
countQuery += " AND status NOT IN ('updated', 'ignored')"
sd = sd.Where(goqu.C("status").NotIn("updated", "ignored"))
}
// Get total count
err := q.db.Get(&count, countQuery, args...)
total, err := Paginated(q.db, sd, uint(filters.Page), uint(filters.PageSize),
goqu.C("last_discovered_at").Desc(),
[]string{"id", "agent_id", "package_type", "package_name", "current_version",
"available_version", "severity", "repository_source", "metadata",
"last_discovered_at", "last_updated_at", "status"},
&updates)
if err != nil {
return nil, 0, fmt.Errorf("failed to get updates count: %w", err)
return nil, 0, err
}
// Add ordering and pagination
baseQuery += " ORDER BY last_discovered_at DESC"
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argIdx, argIdx+1)
args = append(args, filters.PageSize, (filters.Page-1)*filters.PageSize)
// Execute query
err = q.db.Select(&updates, baseQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("failed to list updates: %w", err)
}
return updates, count, nil
return updates, total, nil
}
// GetPackageHistory returns version history for a specific package

View file

@ -71,10 +71,11 @@ var (
// CommandsResponse is returned when an agent checks in for commands
type CommandsResponse struct {
Commands []CommandItem `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
Commands []CommandItem `json:"commands"`
RapidPolling *RapidPollingConfig `json:"rapid_polling,omitempty"`
AcknowledgedIDs []string `json:"acknowledged_ids,omitempty"` // Result IDs server has recorded
ReceiptConfirmedIDs []string `json:"receipt_confirmed_ids,omitempty"` // Command IDs server transitioned sent->received this turn
ConfirmedCommandIDs []string `json:"confirmed_command_ids,omitempty"` // Command IDs server has confirmed completed via ReportLog
}
// RapidPollingConfig contains rapid polling configuration for the agent

View file

@ -148,7 +148,7 @@ type UpdateState struct {
LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Status string `json:"status" db:"status"`
ExpectedSHA256 string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
}
// UpdateHistory represents the version history of a package

View file

@ -364,6 +364,13 @@ func (s *Scheduler) refillRateLimiter() {
}
}
// RemoveSubsystemJob removes a scheduled job for a given agent + subsystem from
// the priority queue. Used by DisableSubsystem so disabling a subsystem stops
// further commands immediately, without waiting for a scheduler reload.
func (s *Scheduler) RemoveSubsystemJob(agentID uuid.UUID, subsystem string) bool {
return s.queue.Remove(agentID, subsystem)
}
// GetStats returns current scheduler statistics (thread-safe)
func (s *Scheduler) GetStats() Stats {
s.mu.RLock()

View file

@ -12,8 +12,8 @@ import (
// Build-time injected version information (SERVER AUTHORITY)
var (
AgentVersion = "0.2.0.5"
ConfigVersion = "0.2.0.5"
AgentVersion = "0.2.0.6"
ConfigVersion = "0.2.0.6"
MinAgentVersion = "0.1.22"
)

View file

@ -10,6 +10,11 @@ import {
Check,
ExternalLink,
X,
Clock,
AlertTriangle,
Loader2,
CheckCircle,
XCircle,
} from 'lucide-react';
import { formatRelativeTime, formatBytes } from '@/lib/utils';
import { updateApi } from '@/lib/api';
@ -35,10 +40,26 @@ interface LogResponse {
result: string;
}
type StatusTab = 'pending' | 'approved' | 'installing' | 'installed' | 'ignored';
const TAB_GROUPS: { key: string; label: string; statuses: string }[] = [
{ key: 'needs-review', label: 'Needs Review', statuses: 'pending' },
{ key: 'in-progress', label: 'In Progress', statuses: 'approved,checking_dependencies,pending_dependencies,installing' },
{ key: 'installed', label: 'Installed', statuses: 'updated' },
{ key: 'failed-ignored', label: 'Failed / Ignored', statuses: 'failed,ignored' },
];
const STATUS_META: Record<string, { label: string; icon: React.ReactNode; class: string }> = {
pending: { label: 'Pending', icon: <Clock className="h-3 w-3" />, class: 'text-gray-600 bg-gray-100' },
approved: { label: 'Approved', icon: <Check className="h-3 w-3" />, class: 'text-blue-600 bg-blue-100' },
checking_dependencies: { label: 'Checking Deps', icon: <Loader2 className="h-3 w-3 animate-spin" />, class: 'text-yellow-600 bg-yellow-100' },
pending_dependencies: { label: 'Deps Pending', icon: <AlertTriangle className="h-3 w-3" />, class: 'text-orange-600 bg-orange-100' },
installing: { label: 'Installing', icon: <RefreshCw className="h-3 w-3 animate-spin" />, class: 'text-purple-600 bg-purple-100' },
updated: { label: 'Installed', icon: <CheckCircle className="h-3 w-3" />, class: 'text-green-600 bg-green-100' },
failed: { label: 'Failed', icon: <XCircle className="h-3 w-3" />, class: 'text-red-600 bg-red-100' },
ignored: { label: 'Ignored', icon: <X className="h-3 w-3" />, class: 'text-gray-500 bg-gray-50' },
};
export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpdatesEnhancedProps) {
const [activeStatus, setActiveStatus] = useState<StatusTab>('pending');
const [activeTab, setActiveTab] = useState(TAB_GROUPS[0].key);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(50);
const [searchTerm, setSearchTerm] = useState('');
@ -48,18 +69,21 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
const [confirmDepsUpdateId, setConfirmDepsUpdateId] = useState<string | null>(null);
const [confirmDepsData, setConfirmDepsData] = useState<string[] | null>(null);
const queryClient = useQueryClient();
const activeGroup = TAB_GROUPS.find(g => g.key === activeTab) || TAB_GROUPS[0];
// Fetch updates with status filter
const { data: updateData, isLoading, error, refetch } = useQuery<AgentUpdateResponse>({
queryKey: ['agent-updates', agentId, activeStatus, currentPage, pageSize, searchTerm, selectedSeverity],
queryKey: ['agent-updates', agentId, activeGroup.statuses, currentPage, pageSize, searchTerm, selectedSeverity],
queryFn: async () => {
const params = {
page: currentPage,
page_size: pageSize,
agent_id: agentId,
status: activeStatus,
status: activeGroup.statuses,
...(searchTerm && { search: searchTerm }),
...(selectedSeverity !== 'all' && { severity: selectedSeverity }),
};
@ -133,6 +157,23 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
},
});
const confirmDepsMutation = useMutation({
mutationFn: async (updateId: string) => {
const response = await updateApi.confirmDependencies(updateId);
return response;
},
onSuccess: () => {
toast.success('Dependencies confirmed, installing');
setConfirmDepsUpdateId(null);
setConfirmDepsData(null);
refetch();
queryClient.invalidateQueries({ queryKey: ['agent-updates'] });
},
onError: (error: any) => {
toast.error(`Failed to confirm dependencies: ${error.message || 'Unknown error'}`);
},
});
const getLogsMutation = useMutation({
mutationFn: async (commandId: string) => {
setIsLoadingLogs(true);
@ -214,6 +255,25 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
}
};
const handleConfirmDeps = async (update: UpdatePackage) => {
const deps: string[] = Array.isArray(update.metadata?.dependencies)
? update.metadata.dependencies
: [];
setConfirmDepsUpdateId(update.id);
setConfirmDepsData(deps);
};
const handleExecuteConfirmDeps = async () => {
if (confirmDepsUpdateId) {
confirmDepsMutation.mutate(confirmDepsUpdateId);
}
};
const handleCancelConfirmDeps = () => {
setConfirmDepsUpdateId(null);
setConfirmDepsData(null);
};
const toggleExpanded = (updateId: string) => {
const newExpanded = new Set(expandedUpdates);
if (newExpanded.has(updateId)) {
@ -251,19 +311,13 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
<div className="space-y-4">
{/* Tabs */}
<div className="flex items-center space-x-1 border-b border-gray-200 text-sm">
{[
{ key: 'pending', label: 'Pending' },
{ key: 'approved', label: 'Approved' },
{ key: 'installing', label: 'Installing' },
{ key: 'installed', label: 'Installed' },
{ key: 'ignored', label: 'Ignored' },
].map((tab) => (
{TAB_GROUPS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveStatus(tab.key as StatusTab)}
onClick={() => { setActiveTab(tab.key); setCurrentPage(1); setSelectedUpdates([]); }}
className={cn(
'px-4 py-2 border-b-2 transition-colors',
activeStatus === tab.key
activeTab === tab.key
? 'border-gray-900 text-gray-900'
: 'border-transparent text-gray-500 hover:text-gray-700'
)}
@ -295,7 +349,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
})}
</div>
{selectedUpdates.length > 0 && activeStatus === 'pending' && (
{selectedUpdates.length > 0 && activeTab === 'needs-review' && (
<button
onClick={handleBulkApprove}
disabled={bulkApproveMutation.isPending}
@ -314,9 +368,6 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
)}
</button>
)}
{/* Header-only view for Update packages - no agent update button here */}
{/* Users should use Agent Health page for agent updates */}
</div>
{/* Search and Filters */}
@ -350,7 +401,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
{/* Updates List */}
{updates.length === 0 ? (
<div className="text-center py-12 text-sm text-gray-500">
{activeStatus === 'installed' ? (
{activeTab === 'installed' ? (
<div>
<p className="mb-2">Installed updates are shown in History</p>
{onNavigateToHistory && (
@ -363,18 +414,22 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
)}
</div>
) : (
`No ${activeStatus} updates`
`No ${activeGroup.label.toLowerCase()} updates`
)}
</div>
) : (
<div className="space-y-px">
{updates.map((update) => {
const isExpanded = expandedUpdates.has(update.id);
const statusMeta = STATUS_META[update.status] || { label: update.status, icon: null, class: 'text-gray-600 bg-gray-100' };
const deps: string[] = Array.isArray(update.metadata?.dependencies)
? update.metadata.dependencies
: [];
return (
<div key={update.id} className="bg-white border-b border-gray-100 last:border-0">
<div className="flex items-center p-2 gap-3">
{/* Checkbox for pending */}
{activeStatus === 'pending' && (
{/* Checkbox for bulk actions */}
{activeTab === 'needs-review' && (
<input
type="checkbox"
checked={selectedUpdates.includes(update.id)}
@ -390,6 +445,15 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
onClick={() => toggleExpanded(update.id)}
>
<div className="flex items-center space-x-3 flex-1 min-w-0">
{/* Status badge */}
<span className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium whitespace-nowrap',
statusMeta.class
)}>
{statusMeta.icon}
{statusMeta.label}
</span>
<span className={cn('px-2 py-0.5 rounded text-xs font-medium', getSeverityColor(update.severity))}>
{update.severity.toUpperCase()}
</span>
@ -398,7 +462,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
{activeStatus === 'pending' && (
{update.status === 'pending' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleApprove(update.id); }}
@ -414,7 +478,7 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
</button>
</>
)}
{activeStatus === 'approved' && (
{update.status === 'approved' && (
<button
onClick={(e) => { e.stopPropagation(); handleInstall(update.id); }}
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
@ -422,10 +486,21 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
Install
</button>
)}
{activeStatus === 'ignored' && (
<span className="text-xs text-gray-500 px-2 py-1">
Rejected
</span>
{update.status === 'pending_dependencies' && deps.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); handleConfirmDeps(update); }}
className="text-xs text-orange-600 hover:text-orange-800 px-2 py-1 border border-orange-200 rounded"
>
Review Dependencies
</button>
)}
{update.status === 'failed' && (
<button
onClick={(e) => { e.stopPropagation(); handleInstall(update.id); }}
className="text-xs text-gray-600 hover:text-gray-900 px-2 py-1"
>
Retry
</button>
)}
{update.recent_command_id && (
<button
@ -477,6 +552,28 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
<div><span className="font-medium">Installed:</span> {formatRelativeTime(update.installed_at)}</div>
)}
</div>
{/* Dependency inline panel for pending_dependencies */}
{update.status === 'pending_dependencies' && deps.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200">
<p className="font-medium text-gray-700 mb-1">
Dependencies ({deps.length})
</p>
<ul className="list-disc list-inside text-gray-600 space-y-0.5 mb-2">
{deps.map((dep, i) => (
<li key={i}>{dep}</li>
))}
</ul>
<div className="flex items-center space-x-2">
<button
onClick={(e) => { e.stopPropagation(); handleConfirmDeps(update); }}
className="text-xs text-orange-600 hover:text-orange-800 px-2 py-1 border border-orange-200 rounded"
>
Confirm & Install
</button>
</div>
</div>
)}
</div>
</div>
)}
@ -512,6 +609,57 @@ export function AgentUpdatesEnhanced({ agentId, onNavigateToHistory }: AgentUpda
</div>
)}
{/* Dependency Confirmation Modal */}
{confirmDepsUpdateId && confirmDepsData !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-lg w-full max-h-[80vh] overflow-hidden">
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900 flex items-center space-x-2">
<AlertTriangle className="h-4 w-4 text-orange-500" />
<span>Confirm Dependencies</span>
</h3>
<button
onClick={handleCancelConfirmDeps}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-3">
{confirmDepsData.length === 0 ? (
<p className="text-sm text-gray-600">No additional dependencies detected. Proceed with installation.</p>
) : (
<>
<p className="text-sm text-gray-600">
The following additional packages will be installed:
</p>
<ul className="list-disc list-inside text-sm text-gray-700 space-y-1">
{confirmDepsData.map((dep, i) => (
<li key={i}>{dep}</li>
))}
</ul>
</>
)}
</div>
<div className="p-4 border-t border-gray-200 flex items-center justify-end space-x-2">
<button
onClick={handleCancelConfirmDeps}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button
onClick={handleExecuteConfirmDeps}
disabled={confirmDepsMutation.isPending}
className="px-3 py-1.5 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50"
>
{confirmDepsMutation.isPending ? 'Confirming...' : 'Confirm & Install'}
</button>
</div>
</div>
</div>
)}
{/* Logs Modal */}
{showLogsModal && logsData && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">

View file

@ -325,6 +325,11 @@ export const updateApi = {
await api.post(`/updates/${id}/install`);
},
// Confirm dependencies and proceed with install
confirmDependencies: async (id: string): Promise<void> => {
await api.post(`/updates/${id}/confirm-dependencies`);
},
// Get update logs
getUpdateLogs: async (id: string, limit?: number): Promise<{ logs: any[]; count: number }> => {
const response = await api.get(`/updates/${id}/logs`, {

View file

@ -1635,6 +1635,11 @@ const Updates: React.FC = () => {
<span className="text-xs font-medium text-gray-900 bg-gray-100 px-2 py-1 rounded">
{update.package_type.toUpperCase()}
</span>
{update.dependencies && update.dependencies.length > 0 && (
<span title={`${update.dependencies.length} dependencies`} className="ml-1 inline-flex items-center text-blue-600">
<Search className="w-3 h-3" />
</span>
)}
</td>
<td className="table-cell">
<div className="text-sm">
@ -1663,6 +1668,11 @@ const Updates: React.FC = () => {
update.status
)}
</span>
{update.dependencies && update.dependencies.length > 0 && (
<span title={`${update.dependencies.length} dependencies`} className="ml-1 inline-flex items-center text-blue-600">
<Search className="w-3 h-3" />
</span>
)}
</td>
<td className="table-cell">
<button

View file

@ -63,6 +63,7 @@ export interface UpdatePackage {
installed_at: string | null;
created_at: string;
recent_command_id?: string;
dependencies?: string[]; // List of dependency packages found during dry run
metadata: Record<string, any>;
}