projection: begin the exact-path public epoch

The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally.

Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba

Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
Fimeg 2026-09-08 21:59:33 -04:00
commit 67e26be2d9
838 changed files with 157302 additions and 0 deletions

13
agent/NOTICE Normal file
View file

@ -0,0 +1,13 @@
RedFlag Agent
Copyright 2024-2025
This software includes code from the following third-party projects:
---
windowsupdate
Copyright 2022 Zheng Dayu
Licensed under the Apache License, Version 2.0
https://github.com/ceshihao/windowsupdate
Included in: agent/pkg/windowsupdate/

165
agent/cmd/agent/cli.go Normal file
View file

@ -0,0 +1,165 @@
package main
import (
"flag"
"fmt"
"os"
"runtime"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/service"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
// CLIFlags holds all command-line flags
type CLI struct {
Register bool
Scan bool
Status bool
LocalStatus bool
InitStandalone bool
ListUpdates bool
Version bool
ServerURL string
Token string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags string
Organization string
DisplayName string
InsecureTLS bool
ExportFormat string
InstallService bool
RemoveService bool
StartService bool
StopService bool
ServiceStatus bool
}
// ParseFlags parses all command-line flags and returns the CLI struct
func ParseFlags() *CLI {
cli := &CLI{}
// Define CLI flags
flag.BoolVar(&cli.Register, "register", false, "Register agent with server")
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
flag.BoolVar(&cli.Version, "version", false, "Show version information")
flag.StringVar(&cli.ServerURL, "server", "", "Server URL")
flag.StringVar(&cli.Token, "token", "", "Registration token for secure enrollment")
flag.StringVar(&cli.ProxyHTTP, "proxy-http", "", "HTTP proxy URL")
flag.StringVar(&cli.ProxyHTTPS, "proxy-https", "", "HTTPS proxy URL")
flag.StringVar(&cli.ProxyNoProxy, "proxy-no", "", "Comma-separated hosts to bypass proxy")
flag.StringVar(&cli.LogLevel, "log-level", "", "Log level (debug, info, warn, error)")
flag.StringVar(&cli.ConfigFile, "config", "", "Configuration file path")
flag.StringVar(&cli.Tags, "tags", "", "Comma-separated tags for agent")
flag.StringVar(&cli.Organization, "organization", "", "Organization/group name")
flag.StringVar(&cli.DisplayName, "name", "", "Display name for agent")
flag.BoolVar(&cli.InsecureTLS, "insecure-tls", false, "Skip TLS certificate verification")
flag.StringVar(&cli.ExportFormat, "export", "", "Export format: json, csv")
// Windows service management commands
flag.BoolVar(&cli.InstallService, "install-service", false, "Install as Windows service")
flag.BoolVar(&cli.RemoveService, "remove-service", false, "Remove Windows service")
flag.BoolVar(&cli.StartService, "start-service", false, "Start Windows service")
flag.BoolVar(&cli.StopService, "stop-service", false, "Stop Windows service")
flag.BoolVar(&cli.ServiceStatus, "service-status", false, "Show Windows service status")
flag.Parse()
return cli
}
// HandleVersionCommand handles the version display command
func HandleVersionCommand() {
fmt.Printf("RedFlag Agent v%s\n", version.Version)
fmt.Printf("Self-hosted update management platform\n")
os.Exit(0)
}
// HandleWindowsServiceCommands handles Windows service management commands
func HandleWindowsServiceCommands(cli *CLI) bool {
if runtime.GOOS != "windows" {
return false
}
switch {
case cli.InstallService:
if err := service.InstallService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to install service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service installed successfully")
os.Exit(0)
case cli.RemoveService:
if err := service.RemoveService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to remove service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service removed successfully")
os.Exit(0)
case cli.StartService:
if err := service.StartService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to start service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service started successfully")
os.Exit(0)
case cli.StopService:
if err := service.StopService(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to stop service: %v\n", err)
os.Exit(1)
}
fmt.Println("RedFlag service stopped successfully")
os.Exit(0)
case cli.ServiceStatus:
if err := service.ServiceStatus(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to get service status: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
return false
}
// ParseTags parses tags from comma-separated string
func ParseTags(tagsStr string) []string {
if tagsStr == "" {
return nil
}
tags := strings.Split(tagsStr, ",")
for i, tag := range tags {
tags[i] = strings.TrimSpace(tag)
}
return tags
}
// ToConfigFlags converts CLI flags to config.CLIFlags
func (cli *CLI) ToConfigFlags() *config.CLIFlags {
return &config.CLIFlags{
ServerURL: cli.ServerURL,
RegistrationToken: cli.Token,
ProxyHTTP: cli.ProxyHTTP,
ProxyHTTPS: cli.ProxyHTTPS,
ProxyNoProxy: cli.ProxyNoProxy,
LogLevel: cli.LogLevel,
ConfigFile: cli.ConfigFile,
Tags: ParseTags(cli.Tags),
Organization: cli.Organization,
DisplayName: cli.DisplayName,
InsecureTLS: cli.InsecureTLS,
}
}

View file

@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/localapi"
)
// HandleLocalStatusCommand displays live agent status through the local IPC API.
// It intentionally runs before config loading, so membership in the local access
// group is enough to inspect local state without reading protected config files.
func HandleLocalStatusCommand(exportFormat string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
snapshot, err := localapi.FetchSnapshot(ctx, localapi.ClientOptions{})
if err != nil {
return err
}
if exportFormat == "json" {
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(snapshot)
}
printLocalStatus(snapshot)
return nil
}
func printLocalStatus(snapshot *localapi.Snapshot) {
identity := snapshot.Identity
status := snapshot.Status
fmt.Println("==================================================================")
fmt.Println("RedFlag Local Agent Status")
fmt.Println("==================================================================")
fmt.Printf("Agent ID: %s\n", identity.AgentID)
fmt.Printf("Server: %s\n", identity.ServerURL)
if identity.Hostname != "" {
fmt.Printf("Hostname: %s\n", identity.Hostname)
}
if identity.OSType != "" {
fmt.Printf("OS Type: %s\n", identity.OSType)
}
if identity.DisplayName != "" {
fmt.Printf("Display Name: %s\n", identity.DisplayName)
}
if len(identity.Tags) > 0 {
fmt.Printf("Tags: %s\n", strings.Join(identity.Tags, ", "))
}
fmt.Printf("Version: %s\n", identity.AgentVersion)
fmt.Printf("Registered: %t\n", identity.Registered)
fmt.Println()
fmt.Printf("Agent Status: %s\n", fallback(status.AgentStatus, "unknown"))
if !status.LastCheckIn.IsZero() {
fmt.Printf("Last Check-in: %s\n", status.LastCheckIn.Format(time.RFC3339))
}
if !status.LastScan.IsZero() {
fmt.Printf("Last Scan: %s\n", status.LastScan.Format(time.RFC3339))
}
fmt.Printf("Updates Available: %d\n", status.UpdateCount)
fmt.Printf("Summary Total: %d\n", status.Summary.Total)
if len(status.Summary.ByEcosystem) > 0 {
fmt.Printf("By Ecosystem: %s\n", formatCounts(status.Summary.ByEcosystem))
}
if len(status.Summary.BySeverity) > 0 {
fmt.Printf("By Severity: %s\n", formatCounts(status.Summary.BySeverity))
}
if len(status.Scanners) > 0 {
fmt.Println()
fmt.Println("Scanners:")
names := make([]string, 0, len(status.Scanners))
for name := range status.Scanners {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
scanner := status.Scanners[name]
line := fmt.Sprintf(" - %s: %s (%d updates)", scanner.Name, fallback(scanner.Status, "unknown"), scanner.UpdateCount)
if scanner.LastError != "" {
line += fmt.Sprintf(" error=%q", scanner.LastError)
}
fmt.Println(line)
}
}
fmt.Println("==================================================================")
}
func formatCounts(counts map[string]int) string {
keys := make([]string, 0, len(counts))
for key := range counts {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", key, counts[key]))
}
return strings.Join(parts, ", ")
}
func fallback(value, replacement string) string {
if value == "" {
return replacement
}
return value
}

238
agent/cmd/agent/main.go Normal file
View file

@ -0,0 +1,238 @@
package main
import (
"fmt"
"log"
"os"
"runtime"
"runtime/debug"
"github.com/Fimeg/RedFlag/agent/internal/agent"
"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/instancelock"
agentLogging "github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/Fimeg/RedFlag/agent/internal/migration"
"github.com/Fimeg/RedFlag/agent/internal/registration"
"github.com/Fimeg/RedFlag/agent/internal/service"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
func main() {
if err := agentLogging.ConfigureProcessLogger(); err != nil {
log.Printf("[ERROR] [agent] [logging] process_logger_init_failed error=%v", err)
}
// Panic recovery - prevents agent crashes from unhandled panics
defer func() {
if r := recover(); r != nil {
log.Printf("[CRITICAL] Agent panic recovered: %v", r)
log.Printf("[CRITICAL] Stack trace: %s", debug.Stack())
os.Exit(1)
}
}()
// Parse CLI flags
cli := ParseFlags()
// Handle version command
if cli.Version {
HandleVersionCommand()
}
if cli.LocalStatus {
if err := HandleLocalStatusCommand(cli.ExportFormat); err != nil {
log.Fatal("Local status command failed:", err)
}
return
}
// Handle Windows service management commands
if HandleWindowsServiceCommands(cli) {
return
}
// Determine config path
configPath := constants.GetAgentConfigPath()
if cli.ConfigFile != "" {
configPath = cli.ConfigFile
}
// Check for migration requirements
if err := handleMigration(configPath); err != nil {
log.Printf("Warning: Migration handling failed: %v", err)
}
// Load configuration with priority: CLI > env > file > defaults
cfg, err := config.Load(configPath, cli.ToConfigFlags())
if err != nil {
log.Fatal("Failed to load configuration:", err)
}
// Update agent version in config if changed
if cfg.AgentVersion != version.Version {
cfg.AgentVersion = version.Version
if err := cfg.Save(configPath); err != nil {
log.Printf("Warning: Failed to update agent version in config: %v", err)
}
}
if cli.InitStandalone {
if err := cfg.InitializeStandalone(); err != nil {
log.Fatal("Standalone initialization failed: ", err)
}
if err := cfg.Save(configPath); err != nil {
log.Fatal("Standalone configuration save failed: ", err)
}
fmt.Println(cfg.AgentID.String())
return
}
// Handle registration command
if cli.Register {
if cfg.IsStandalone() {
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
}
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
log.Fatal("Registration failed:", err)
}
return
}
// Handle scan command
if cli.Scan {
if err := handlers.ScanCommand(cfg, cli.ExportFormat); err != nil {
log.Fatal("Scan failed:", err)
}
return
}
// Handle status command
if cli.Status {
if err := handlers.StatusCommand(cfg); err != nil {
log.Fatal("Status command failed:", err)
}
return
}
// Handle list-updates command
if cli.ListUpdates {
if err := handlers.ListUpdatesCommand(cfg, cli.ExportFormat); err != nil {
log.Fatal("List updates failed:", err)
}
return
}
// Acquire an exclusive instance lock to prevent two agent processes
// from sharing the same config.json and renewal state. The lock is
// released when this process exits (fd closes on os.Exit too).
unlock, err := instancelock.Acquire()
if err != nil {
log.Fatalf("[FATAL] instance_lock_failed another_instance_running error=%v", err)
}
defer unlock()
// Check if registered
if !cfg.IsRegistered() && !cfg.IsStandalone() {
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
}
// Check if running as Windows service
if runtime.GOOS == "windows" && service.IsService() {
if err := service.RunService(cfg); err != nil {
log.Fatal("Service failed:", err)
}
return
}
// Start agent service (console mode)
if err := agent.RunAgentLoop(cfg); err != nil {
log.Fatal("Agent failed:", err)
}
}
// handleMigration checks and executes migrations if needed
func handleMigration(configPath string) error {
migrationConfig := migration.NewFileDetectionConfig()
migrationConfig.OldConfigPath = constants.LegacyConfigPath
migrationConfig.OldStatePath = constants.LegacyStatePath
migrationConfig.NewConfigPath = constants.GetAgentConfigDir()
migrationConfig.NewStatePath = constants.GetAgentStateDir()
migrationDetection, err := migration.DetectMigrationRequirements(migrationConfig)
if err != nil {
return fmt.Errorf("failed to detect migration requirements: %w", err)
}
if !migrationDetection.RequiresMigration {
return nil
}
log.Printf("[RedFlag Server Migrator] Migration detected: %s → %s",
migrationDetection.CurrentAgentVersion, version.Version)
log.Printf("[RedFlag Server Migrator] Required migrations: %v",
migrationDetection.RequiredMigrations)
migrationPlan := &migration.MigrationPlan{
Detection: migrationDetection,
TargetVersion: version.Version,
Config: migrationConfig,
BackupPath: constants.GetMigrationBackupDir(),
}
executor := migration.NewMigrationExecutor(migrationPlan, configPath)
result, err := executor.ExecuteMigration()
if err != nil {
log.Printf("[RedFlag Server Migrator] Migration failed: %v", err)
log.Printf("[RedFlag Server Migrator] Backup available at: %s", result.BackupPath)
return err
}
log.Printf("[RedFlag Server Migrator] Migration completed successfully")
if result.RollbackAvailable {
log.Printf("[RedFlag Server Migrator] Rollback available at: %s", result.BackupPath)
}
return nil
}
// handleRegistration handles the agent registration flow
func handleRegistration(cfg *config.Config, serverURL string) error {
// Validate server URL for Windows users
if runtime.GOOS == "windows" && serverURL == "" {
fmt.Println("❌ CONFIGURATION REQUIRED!")
fmt.Println("==================================================================")
fmt.Println("Please configure the server URL before registering:")
fmt.Println("")
fmt.Println("Option 1 - Use the -server flag:")
fmt.Println(" redflag-agent.exe -register -server https://your-server.com")
fmt.Println("")
fmt.Println("Option 2 - Use environment variable:")
fmt.Println(" set REDFLAG_SERVER_URL=https://your-server.com")
fmt.Println(" redflag-agent.exe -register")
fmt.Println("")
fmt.Println("Option 3 - Create a .env file:")
fmt.Println(" REDFLAG_SERVER_URL=https://your-server.com")
fmt.Println("==================================================================")
os.Exit(1)
}
// Use registration package for the actual registration
if err := registration.RegisterAgent(cfg, serverURL); err != nil {
return err
}
fmt.Println("==================================================================")
fmt.Println("🎉 AGENT REGISTRATION SUCCESSFUL!")
fmt.Println("==================================================================")
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.Println("==================================================================")
fmt.Println("💡 Save this Agent ID for your records!")
fmt.Println("🚀 You can now start the agent without flags")
fmt.Println("")
return nil
}

View file

@ -0,0 +1,82 @@
package main
// ethos_emoji_test.go — Tests for emoji in agent main.go log statements.
// D-2 FIXED: emoji removed from token renewal and install result log paths.
// EXCLUDES: registration CLI output and startup banner (exempt).
import (
"os"
"strings"
"testing"
)
func hasEmojiRune(s string) bool {
for _, r := range s {
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
return true
}
}
return false
}
// isExemptLine checks if a line number falls in an exempt range.
// Exempt ranges are user-facing CLI output (registration, startup banner).
func isExemptLine(lineNum int) bool {
// Registration CLI output: ~lines 294-322
if lineNum >= 290 && lineNum <= 330 {
return true
}
// Startup banner: ~lines 691-700
if lineNum >= 685 && lineNum <= 705 {
return true
}
return false
}
func TestMainGoHasEmojiInLogStatements(t *testing.T) {
// POST-FIX: No emoji in non-exempt log statements.
content, err := os.ReadFile("agent/main.go")
if err != nil {
t.Fatalf("failed to read agent/main.go: %v", err)
}
lines := strings.Split(string(content), "\n")
emojiLogCount := 0
for i, line := range lines {
if isExemptLine(i + 1) {
continue
}
trimmed := strings.TrimSpace(line)
isLog := strings.Contains(trimmed, "log.Printf") || strings.Contains(trimmed, "log.Println")
if isLog && hasEmojiRune(trimmed) {
emojiLogCount++
}
}
if emojiLogCount > 0 {
t.Errorf("[ERROR] [agent] [main] D-2 NOT FIXED: %d non-exempt log statements with emoji", emojiLogCount)
}
t.Log("[INFO] [agent] [main] D-2 FIXED: no emoji in non-exempt log statements")
}
func TestMainGoLogStatementsHaveNoEmoji(t *testing.T) {
content, err := os.ReadFile("agent/main.go")
if err != nil {
t.Fatalf("failed to read agent/main.go: %v", err)
}
lines := strings.Split(string(content), "\n")
for i, line := range lines {
if isExemptLine(i + 1) {
continue
}
trimmed := strings.TrimSpace(line)
isLog := strings.Contains(trimmed, "log.Printf") || strings.Contains(trimmed, "log.Println")
if isLog && hasEmojiRune(trimmed) {
t.Errorf("[ERROR] [agent] [main] emoji in non-exempt log at line %d", i+1)
}
}
}

43
agent/go.mod Normal file
View file

@ -0,0 +1,43 @@
module github.com/Fimeg/RedFlag/agent
go 1.26.6
require (
github.com/Microsoft/go-winio v0.4.21
github.com/cilium/ebpf v0.22.0
github.com/denisbrodbeck/machineid v1.0.1
github.com/docker/docker v27.4.1+incompatible
github.com/go-ole/go-ole v1.3.0
github.com/gofrs/uuid/v5 v5.4.0
github.com/scjalliance/comshim v0.0.0-20250111221056-b2ef9d8d7e0f
golang.org/x/sys v0.47.0
)
require (
github.com/cespare/xxhash/v2 v2.3.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
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/time v0.5.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)

154
agent/go.sum Normal file
View file

@ -0,0 +1,154 @@
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/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY=
github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0=
github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=

245
agent/install.sh Executable file
View file

@ -0,0 +1,245 @@
#!/bin/bash
set -e
# RedFlag Agent Installation Script
# This script installs the RedFlag agent as a systemd service with proper permissions
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AGENT_USER="redflag-agent"
AGENT_HOME="/var/lib/redflag-agent"
AGENT_BINARY="/usr/local/bin/redflag-agent"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent"
SERVICE_FILE="/etc/systemd/system/redflag-agent.service"
echo "=== RedFlag Agent Installation ==="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: This script must be run as root (use sudo)"
exit 1
fi
# Function to create user if doesn't exist
create_user() {
if id "$AGENT_USER" &>/dev/null; then
echo "✓ User $AGENT_USER already exists"
else
echo "Creating system user $AGENT_USER..."
useradd -r -s /bin/false -d "$AGENT_HOME" -m "$AGENT_USER"
echo "✓ User $AGENT_USER created"
fi
# Add user to docker group for Docker update scanning
if getent group docker &>/dev/null; then
echo "Adding $AGENT_USER to docker group..."
usermod -aG docker "$AGENT_USER"
echo "✓ User $AGENT_USER added to docker group"
else
echo "⚠ Docker group not found - Docker updates will not be available"
echo " (Install Docker first, then reinstall the agent to enable Docker support)"
fi
}
# Function to build agent binary
build_agent() {
echo "Building agent binary..."
cd "$SCRIPT_DIR"
go build -o redflag-agent ./cmd/agent
echo "✓ Agent binary built"
}
# Function to install agent binary
install_binary() {
echo "Installing agent binary to $AGENT_BINARY..."
cp "$SCRIPT_DIR/redflag-agent" "$AGENT_BINARY"
chmod 755 "$AGENT_BINARY"
chown root:root "$AGENT_BINARY"
echo "✓ Agent binary installed"
# Set SELinux context for binary if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux detected, setting file context for binary..."
restorecon -v "$AGENT_BINARY" || echo "[WARNING] [installer] [selinux] restorecon_failed path=$AGENT_BINARY — continuing"
echo "✓ SELinux context set for binary"
fi
}
# Function to install sudoers configuration
install_sudoers() {
echo "Installing sudoers configuration..."
cat > "$SUDOERS_FILE" <<'EOF'
# RedFlag Agent minimal sudo permissions
# This file is generated automatically during RedFlag agent installation
# APT package management commands
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get update
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/apt-get install --dry-run --yes *
# DNF package management commands
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf makecache
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf upgrade -y *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/dnf install --assumeno --downloadonly *
# Docker operations
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker pull *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker image inspect *
redflag-agent ALL=(root) NOPASSWD: /usr/bin/docker manifest inspect *
EOF
chmod 440 "$SUDOERS_FILE"
# Validate sudoers file
if visudo -c -f "$SUDOERS_FILE"; then
echo "✓ Sudoers configuration installed and validated"
else
echo "ERROR: Sudoers configuration is invalid"
rm -f "$SUDOERS_FILE"
exit 1
fi
}
# Function to install systemd service
install_service() {
echo "Installing systemd service..."
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=RedFlag Update Agent
After=network.target
[Service]
Type=simple
User=$AGENT_USER
Group=$AGENT_USER
WorkingDirectory=$AGENT_HOME
ExecStart=$AGENT_BINARY
Restart=always
RestartSec=30
# Security hardening
# NoNewPrivileges=true - DISABLED: Prevents sudo from working
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=$AGENT_HOME /var/log /etc/aggregator
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "$SERVICE_FILE"
echo "✓ Systemd service installed"
}
# Function to start and enable service
start_service() {
echo "Reloading systemd daemon..."
systemctl daemon-reload
# Stop service if running
if systemctl is-active --quiet redflag-agent; then
echo "Stopping existing service..."
systemctl stop redflag-agent
fi
echo "Enabling and starting redflag-agent service..."
systemctl enable redflag-agent
systemctl start redflag-agent
# Wait a moment for service to start
sleep 2
echo "✓ Service started"
}
# Function to show status
show_status() {
echo ""
echo "=== Service Status ==="
systemctl status redflag-agent --no-pager -l
echo ""
echo "=== Recent Logs ==="
journalctl -u redflag-agent -n 20 --no-pager
}
# Function to register agent
register_agent() {
local server_url="${1:-http://localhost:8080}"
echo "Registering agent with server at $server_url..."
# Create config directory
mkdir -p /etc/aggregator
# Set SELinux context for config directory if SELinux is enabled
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Setting SELinux context for config directory..."
restorecon -Rv /etc/aggregator || echo "[WARNING] [installer] [selinux] restorecon_failed path=/etc/aggregator — continuing"
echo "✓ SELinux context set for config directory"
fi
# Register agent (run as regular binary, not as service)
if "$AGENT_BINARY" -register -server "$server_url"; then
echo "✓ Agent registered successfully"
else
echo "ERROR: Agent registration failed"
echo "Please ensure the RedFlag server is running at $server_url"
exit 1
fi
}
# Main installation flow
SERVER_URL="${1:-http://localhost:8080}"
echo "Step 1: Creating system user..."
create_user
echo ""
echo "Step 2: Building agent binary..."
build_agent
echo ""
echo "Step 3: Installing agent binary..."
install_binary
echo ""
echo "Step 4: Registering agent with server..."
register_agent "$SERVER_URL"
echo ""
echo "Step 5: Setting config file permissions..."
chown redflag-agent:redflag-agent /etc/redflag/agent/config.json
chmod 600 /etc/redflag/agent/config.json
echo ""
echo "Step 6: Installing sudoers configuration..."
install_sudoers
echo ""
echo "Step 7: Installing systemd service..."
install_service
echo ""
echo "Step 8: Starting service..."
start_service
echo ""
echo "=== Installation Complete ==="
echo ""
echo "The RedFlag agent is now installed and running as a systemd service."
echo "Server URL: $SERVER_URL"
echo ""
echo "Useful commands:"
echo " - Check status: sudo systemctl status redflag-agent"
echo " - View logs: sudo journalctl -u redflag-agent -f"
echo " - Restart: sudo systemctl restart redflag-agent"
echo " - Stop: sudo systemctl stop redflag-agent"
echo " - Disable: sudo systemctl disable redflag-agent"
echo ""
echo "Note: To re-register with a different server, edit /etc/aggregator/config.json"
echo ""
show_status

View file

@ -0,0 +1,202 @@
package acknowledgment
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// PendingResult represents a command result awaiting acknowledgment
type PendingResult struct {
CommandID string `json:"command_id"`
SentAt time.Time `json:"sent_at"`
RetryCount int `json:"retry_count"`
}
// Tracker manages pending acknowledgments for command results
type Tracker struct {
pending map[string]*PendingResult
mu sync.RWMutex
filePath string
maxAge time.Duration // Max time to keep pending (default 24h)
maxRetries int // Max retries before giving up (default 10)
}
// NewTracker creates a new acknowledgment tracker
func NewTracker(statePath string) *Tracker {
return &Tracker{
pending: make(map[string]*PendingResult),
filePath: filepath.Join(statePath, "pending_acks.json"),
maxAge: 24 * time.Hour,
maxRetries: 10,
}
}
// Load restores pending acknowledgments from disk
func (t *Tracker) Load() error {
t.mu.Lock()
defer t.mu.Unlock()
// If file doesn't exist, that's fine (fresh start)
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(t.filePath)
if err != nil {
return fmt.Errorf("failed to read pending acks: %w", err)
}
if len(data) == 0 {
return nil // Empty file
}
var pending map[string]*PendingResult
if err := json.Unmarshal(data, &pending); err != nil {
return fmt.Errorf("failed to parse pending acks: %w", err)
}
t.pending = pending
return nil
}
// Save persists pending acknowledgments to disk
func (t *Tracker) Save() error {
t.mu.RLock()
defer t.mu.RUnlock()
// Ensure directory exists
dir := filepath.Dir(t.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create ack directory: %w", err)
}
data, err := json.MarshalIndent(t.pending, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal pending acks: %w", err)
}
if err := os.WriteFile(t.filePath, data, 0600); err != nil {
return fmt.Errorf("failed to write pending acks: %w", err)
}
return nil
}
// Add marks a command result as pending acknowledgment
func (t *Tracker) Add(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
t.pending[commandID] = &PendingResult{
CommandID: commandID,
SentAt: time.Now().UTC(),
RetryCount: 0,
}
}
// Acknowledge marks command results as acknowledged and removes them
func (t *Tracker) Acknowledge(commandIDs []string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, id := range commandIDs {
delete(t.pending, id)
}
}
// GetPending returns list of command IDs awaiting acknowledgment
func (t *Tracker) GetPending() []string {
t.mu.RLock()
defer t.mu.RUnlock()
ids := make([]string, 0, len(t.pending))
for id := range t.pending {
ids = append(ids, id)
}
return ids
}
// IncrementRetry increments retry count for a command
func (t *Tracker) IncrementRetry(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
if result, exists := t.pending[commandID]; exists {
result.RetryCount++
}
}
// DroppedResult describes a pending result-ack that Cleanup abandoned. The agent
// only ever redelivers the command ID (not the result payload), so a result the
// server never recorded can sit here unrecoverable until it ages out — and a drop
// is the silent loss of an auditable event. Cleanup returns these so the caller
// journals each one inward (ETHOS #1) rather than discarding it to /dev/null.
type DroppedResult struct {
CommandID string
Reason string // "max_age" | "max_retries"
RetryCount int
AgeSeconds int
}
// Cleanup removes old or over-retried pending results and returns what it dropped
// so the loss can be recorded as history. Returns an empty slice when nothing aged out.
func (t *Tracker) Cleanup() []DroppedResult {
t.mu.Lock()
defer t.mu.Unlock()
now := time.Now().UTC()
var dropped []DroppedResult
for id, result := range t.pending {
age := now.Sub(result.SentAt)
switch {
case age > t.maxAge:
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_age", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
case result.RetryCount >= t.maxRetries:
dropped = append(dropped, DroppedResult{CommandID: id, Reason: "max_retries", RetryCount: result.RetryCount, AgeSeconds: int(age.Seconds())})
delete(t.pending, id)
}
}
return dropped
}
// Stats returns statistics about pending acknowledgments
func (t *Tracker) Stats() Stats {
t.mu.RLock()
defer t.mu.RUnlock()
stats := Stats{
Total: len(t.pending),
}
now := time.Now().UTC()
for _, result := range t.pending {
age := now.Sub(result.SentAt)
if age > 1*time.Hour {
stats.OlderThan1Hour++
}
if result.RetryCount > 0 {
stats.WithRetries++
}
if result.RetryCount >= 5 {
stats.HighRetries++
}
}
return stats
}
// Stats holds statistics about pending acknowledgments
type Stats struct {
Total int
OlderThan1Hour int
WithRetries int
HighRetries int
}

View file

@ -0,0 +1,54 @@
package agent
import (
"errors"
"fmt"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// TestClassifyFailure locks in the BUG-014 policy: dead credentials and
// machine-binding mismatches are terminal; everything else is transient.
func TestClassifyFailure(t *testing.T) {
cases := []struct {
name string
err error
want failureClass
}{
{"machine mismatch", client.ErrMachineMismatch, failureTerminal},
{"refresh token invalid", client.ErrRefreshTokenInvalid, failureTerminal},
{"wrapped machine mismatch", fmt.Errorf("get commands: %w", client.ErrMachineMismatch), failureTerminal},
{"wrapped refresh invalid", fmt.Errorf("renew: %w", client.ErrRefreshTokenInvalid), failureTerminal},
{"unauthorized alone is not terminal (renewal may fix it)", client.ErrUnauthorized, failureTransient},
{"plain network error", errors.New("dial tcp: connection refused"), failureTransient},
{"nil-adjacent generic error", errors.New("502 bad gateway"), failureTransient},
}
for _, tc := range cases {
if got := classifyFailure(tc.err); got != tc.want {
t.Errorf("%s: classifyFailure() = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestDelayForFailure verifies the policy curves: terminal is a long flat
// delay independent of attempt count; transient follows the jittered
// exponential bounded by base and max.
func TestDelayForFailure(t *testing.T) {
base := 5 * time.Second
max := 5 * time.Minute
for _, attempt := range []int{1, 3, 50} {
if got := delayForFailure(failureTerminal, attempt, base, max); got != terminalRetryDelay {
t.Errorf("terminal attempt %d: delay = %s, want flat %s", attempt, got, terminalRetryDelay)
}
}
for attempt := 1; attempt <= 30; attempt++ {
got := delayForFailure(failureTransient, attempt, base, max)
if got < base || got > max {
t.Errorf("transient attempt %d: delay %s outside [%s, %s]", attempt, got, base, max)
}
}
}

1232
agent/internal/agent/loop.go Normal file

File diff suppressed because it is too large Load diff

4
agent/internal/cache/cache.go vendored Normal file
View file

@ -0,0 +1,4 @@
package cache
// Init initializes the cache module
func Init() {}

172
agent/internal/cache/hash_cache.go vendored Normal file
View file

@ -0,0 +1,172 @@
package cache
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// HashCache provides LRU caching for expected package hashes
type HashCache struct {
mu sync.RWMutex
hashes map[string]string
maxSize int
evicted map[string]time.Time
}
// NewHashCache creates a new hash cache
func NewHashCache(maxSize int) *HashCache {
return &HashCache{
hashes: make(map[string]string),
maxSize: maxSize,
evicted: make(map[string]time.Time),
}
}
// key creates a cache key from package type and name
func (c *HashCache) key(packageType, packageName, version string) string {
return fmt.Sprintf("%s:%s:%s", packageType, packageName, version)
}
// Get retrieves a cached hash
func (c *HashCache) Get(packageType, packageName, version string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
key := c.key(packageType, packageName, version)
hash, ok := c.hashes[key]
return hash, ok
}
// Set stores a hash in the cache
func (c *HashCache) Set(packageType, packageName, version, hash string) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.key(packageType, packageName, version)
// Remove old entry if exists
if _, ok := c.hashes[key]; ok {
c.evict(key)
}
c.hashes[key] = hash
c.touch(key)
// Enforce size limit
for len(c.hashes) > c.maxSize {
c.evictOldest()
}
}
// Delete removes a hash from the cache
func (c *HashCache) Delete(packageType, packageName, version string) {
c.mu.Lock()
defer c.mu.Unlock()
key := c.key(packageType, packageName, version)
delete(c.hashes, key)
}
// evict removes a key from the cache
func (c *HashCache) evict(key string) {
delete(c.hashes, key)
delete(c.evicted, key)
}
// touch updates the access time for a key
func (c *HashCache) touch(key string) {
c.evicted[key] = time.Now()
}
// evictOldest removes the oldest accessed key when cache is full
func (c *HashCache) evictOldest() {
var oldest string
var oldestTime time.Time
c.mu.RLock()
for key, t := range c.evicted {
if oldest == "" || t.Before(oldestTime) {
oldest = key
oldestTime = t
}
}
c.mu.RUnlock()
if oldest != "" {
c.evict(oldest)
}
}
// VerifyHashFromCache downloads a package only if not cached, verifies hash, and caches result
func (c *HashCache) VerifyHashFromCache(packageType, packageName, version, expectedSHA256 string) error {
if expectedSHA256 == "" {
return nil
}
// Check cache first
if cachedHash, cached := c.Get(packageType, packageName, version); cached {
if cachedHash == expectedSHA256 {
return nil // Hash verified from cache
}
// Cache has different hash - re-download to update cache
}
// Download and compute hash
resp, err := http.Get(fmt.Sprintf("%s/api/v1/downloads/artifact?ecosystem=%s&package_name=%s&version=%s",
getDownloaderURL(), packageType, packageName, version))
if err != nil {
return fmt.Errorf("failed to download package: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
// Compute hash while streaming
h := sha256.New()
_, err = io.Copy(h, resp.Body)
if err != nil {
return fmt.Errorf("failed to read package: %w", err)
}
computedSHA256 := hex.EncodeToString(h.Sum(nil))
// Verify against expected
if computedSHA256 != expectedSHA256 {
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, computedSHA256)
}
// Cache the verified hash
c.Set(packageType, packageName, version, computedSHA256)
return nil
}
// Clear evicts all cached hashes
func (c *HashCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.hashes = make(map[string]string)
c.evicted = make(map[string]time.Time)
}
// Size returns current cache size
func (c *HashCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.hashes)
}
// getDownloaderURL returns the URL of the download handler
// This is a simplified version - in production, use the download handler's getServerURL method
func getDownloaderURL() string {
// Default to localhost for local testing
// In production, this would come from config
return "http://localhost:8080"
}

314
agent/internal/cache/local.go vendored Normal file
View file

@ -0,0 +1,314 @@
package cache
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
// LocalCache stores scan results locally for offline viewing
type LocalCache struct {
LastScanTime time.Time `json:"last_scan_time"`
LastCheckIn time.Time `json:"last_check_in"`
AgentID uuid.UUID `json:"agent_id"`
ServerURL string `json:"server_url"`
UpdateCount int `json:"update_count"`
Updates []client.UpdateReportItem `json:"updates"`
AgentStatus string `json:"agent_status"`
Summary UpdateSummary `json:"summary"`
Scanners map[string]ScannerState `json:"scanners,omitempty"`
Capabilities CapabilityTokenState `json:"capabilities"`
LastUpdated time.Time `json:"last_updated"`
}
// UpdateSummary is the local rollup consumed by status surfaces.
type UpdateSummary struct {
Total int `json:"total"`
ByEcosystem map[string]int `json:"by_ecosystem,omitempty"`
BySeverity map[string]int `json:"by_severity,omitempty"`
}
// ScannerState records the latest local scan status for one scanner.
type ScannerState struct {
Name string `json:"name"`
Status string `json:"status"`
LastScanTime time.Time `json:"last_scan_time,omitempty"`
LastDurationMS int64 `json:"last_duration_ms,omitempty"`
LastError string `json:"last_error,omitempty"`
UpdateCount int `json:"update_count"`
}
// CapabilityTokenState is count-only local state for the supply-chain token path.
// It intentionally excludes token IDs, token payloads, signatures, and artifacts.
type CapabilityTokenState struct {
LastFetchTime time.Time `json:"last_fetch_time,omitempty"`
LastProcessTime time.Time `json:"last_process_time,omitempty"`
PendingCount int `json:"pending_count"`
LastFetchedCount int `json:"last_fetched_count"`
LastProcessedCount int `json:"last_processed_count"`
LastFailedCount int `json:"last_failed_count"`
LastError string `json:"last_error,omitempty"`
}
// cacheFile is the file where scan results are cached
const cacheFile = "last_scan.json"
// GetCachePath returns the full path to the cache file
func GetCachePath() string {
return filepath.Join(constants.GetAgentCacheDir(), cacheFile)
}
// Load reads the local cache from disk
func Load() (*LocalCache, error) {
return LoadFromPath(GetCachePath())
}
// LoadFromPath reads a local cache file from disk.
func LoadFromPath(cachePath string) (*LocalCache, error) {
// Check if cache file exists
if _, err := os.Stat(cachePath); os.IsNotExist(err) {
// Return empty cache if file doesn't exist
return &LocalCache{}, nil
}
// Read cache file
data, err := os.ReadFile(cachePath)
if err != nil {
return nil, fmt.Errorf("failed to read cache file: %w", err)
}
var cache LocalCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, fmt.Errorf("failed to parse cache file: %w", err)
}
return &cache, nil
}
// Save writes the local cache to disk
func (c *LocalCache) Save() error {
return c.SaveToPath(GetCachePath())
}
// SaveToPath writes a local cache file to disk.
func (c *LocalCache) SaveToPath(cachePath string) error {
// Ensure cache directory exists
if err := os.MkdirAll(filepath.Dir(cachePath), 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
c.refreshSummary()
if c.LastUpdated.IsZero() {
c.LastUpdated = time.Now().UTC()
}
// Marshal cache to JSON with indentation
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal cache: %w", err)
}
// Write cache file with restricted permissions
if err := os.WriteFile(cachePath, data, 0600); err != nil {
return fmt.Errorf("failed to write cache file: %w", err)
}
return nil
}
// UpdateScanResults updates the cache with new scan results
func (c *LocalCache) UpdateScanResults(updates []client.UpdateReportItem) {
now := time.Now().UTC()
c.LastScanTime = now
c.LastUpdated = now
c.Updates = updates
c.UpdateCount = len(updates)
c.refreshSummary()
}
// UpdateCheckIn updates the last check-in time
func (c *LocalCache) UpdateCheckIn() {
now := time.Now().UTC()
c.LastCheckIn = now
c.LastUpdated = now
}
// SetAgentInfo sets agent identification information
func (c *LocalCache) SetAgentInfo(agentID uuid.UUID, serverURL string) {
c.AgentID = agentID
c.ServerURL = serverURL
c.LastUpdated = time.Now().UTC()
}
// SetAgentStatus sets the current agent status
func (c *LocalCache) SetAgentStatus(status string) {
c.AgentStatus = status
c.LastUpdated = time.Now().UTC()
}
// RecordScannerResult updates the local read model with one scanner execution.
// Successful package-update scans replace only their ecosystem slice, so a
// scan_apt command does not erase the latest DNF/Winget/Docker observations.
func (c *LocalCache) RecordScannerResult(scannerName, status string, updates []client.UpdateReportItem, scanErr error, duration time.Duration, affectsUpdateList bool) {
name := normalizeScannerName(scannerName)
if status == "" {
status = "unknown"
}
if c.Scanners == nil {
c.Scanners = make(map[string]ScannerState)
}
now := time.Now().UTC()
lastError := ""
if scanErr != nil {
lastError = scanErr.Error()
}
c.Scanners[name] = ScannerState{
Name: name,
Status: status,
LastScanTime: now,
LastDurationMS: duration.Milliseconds(),
LastError: lastError,
UpdateCount: len(updates),
}
c.LastUpdated = now
if affectsUpdateList && status == "success" {
c.replaceScannerUpdates(name, updates)
c.LastScanTime = now
c.UpdateCount = len(c.Updates)
c.refreshSummary()
}
}
// RecordCapabilityTokenFetch records the count of tokens fetched on the latest poll.
func (c *LocalCache) RecordCapabilityTokenFetch(fetched int, fetchErr error) {
now := time.Now().UTC()
c.Capabilities.LastFetchTime = now
c.Capabilities.LastFetchedCount = fetched
c.Capabilities.PendingCount = fetched
c.Capabilities.LastError = ""
if fetchErr != nil {
c.Capabilities.LastError = fetchErr.Error()
}
c.LastUpdated = now
}
// RecordCapabilityTokenProcess records count-only token processing results.
func (c *LocalCache) RecordCapabilityTokenProcess(processed, failed int) {
now := time.Now().UTC()
c.Capabilities.LastProcessTime = now
c.Capabilities.LastProcessedCount = processed
c.Capabilities.LastFailedCount = failed
remaining := c.Capabilities.LastFetchedCount - processed - failed
if remaining < 0 {
remaining = 0
}
c.Capabilities.PendingCount = remaining
c.LastUpdated = now
}
// IsExpired checks if the cache is older than the specified duration
func (c *LocalCache) IsExpired(maxAge time.Duration) bool {
return time.Since(c.LastScanTime) > maxAge
}
// GetUpdatesByType returns updates filtered by package type
func (c *LocalCache) GetUpdatesByType(packageType string) []client.UpdateReportItem {
var filtered []client.UpdateReportItem
for _, update := range c.Updates {
if update.PackageType == packageType {
filtered = append(filtered, update)
}
}
return filtered
}
// Clear clears the cache
func (c *LocalCache) Clear() {
c.LastScanTime = time.Time{}
c.LastCheckIn = time.Time{}
c.UpdateCount = 0
c.Updates = []client.UpdateReportItem{}
c.AgentStatus = ""
c.Summary = UpdateSummary{}
c.Scanners = nil
c.Capabilities = CapabilityTokenState{}
c.LastUpdated = time.Now().UTC()
}
func (c *LocalCache) replaceScannerUpdates(scannerName string, updates []client.UpdateReportItem) {
packageTypes := packageTypesForScanner(scannerName, updates)
if len(packageTypes) == 0 {
return
}
filtered := make([]client.UpdateReportItem, 0, len(c.Updates)+len(updates))
for _, update := range c.Updates {
if _, replace := packageTypes[normalizeScannerName(update.PackageType)]; replace {
continue
}
filtered = append(filtered, update)
}
filtered = append(filtered, updates...)
c.Updates = filtered
}
func (c *LocalCache) refreshSummary() {
summary := UpdateSummary{
Total: len(c.Updates),
ByEcosystem: make(map[string]int),
BySeverity: make(map[string]int),
}
for _, update := range c.Updates {
ecosystem := normalizeScannerName(update.PackageType)
if ecosystem == "" {
ecosystem = "unknown"
}
severity := strings.ToLower(strings.TrimSpace(update.Severity))
if severity == "" {
severity = "unknown"
}
summary.ByEcosystem[ecosystem]++
summary.BySeverity[severity]++
}
if len(summary.ByEcosystem) == 0 {
summary.ByEcosystem = nil
}
if len(summary.BySeverity) == 0 {
summary.BySeverity = nil
}
c.UpdateCount = summary.Total
c.Summary = summary
}
func packageTypesForScanner(scannerName string, updates []client.UpdateReportItem) map[string]struct{} {
packageTypes := make(map[string]struct{})
switch normalizeScannerName(scannerName) {
case "apt", "dnf", "pacman", "docker", "winget":
packageTypes[normalizeScannerName(scannerName)] = struct{}{}
case "windows":
packageTypes["windows_update"] = struct{}{}
packageTypes["windows_update_history"] = struct{}{}
}
for _, update := range updates {
if packageType := normalizeScannerName(update.PackageType); packageType != "" {
packageTypes[packageType] = struct{}{}
}
}
return packageTypes
}
func normalizeScannerName(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}

139
agent/internal/cache/local_test.go vendored Normal file
View file

@ -0,0 +1,139 @@
package cache
import (
"errors"
"path/filepath"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
func TestSaveLoadFromPathRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "last_scan.json")
localCache := &LocalCache{
AgentStatus: "online",
Updates: []client.UpdateReportItem{
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
},
}
localCache.UpdateScanResults(localCache.Updates)
if err := localCache.SaveToPath(path); err != nil {
t.Fatalf("SaveToPath() error = %v", err)
}
loaded, err := LoadFromPath(path)
if err != nil {
t.Fatalf("LoadFromPath() error = %v", err)
}
if loaded.AgentStatus != "online" {
t.Fatalf("AgentStatus = %q, want online", loaded.AgentStatus)
}
if loaded.Summary.Total != 1 {
t.Fatalf("Summary.Total = %d, want 1", loaded.Summary.Total)
}
if loaded.Summary.ByEcosystem["apt"] != 1 {
t.Fatalf("Summary.ByEcosystem[apt] = %d, want 1", loaded.Summary.ByEcosystem["apt"])
}
if loaded.Summary.BySeverity["important"] != 1 {
t.Fatalf("Summary.BySeverity[important] = %d, want 1", loaded.Summary.BySeverity["important"])
}
}
func TestRecordScannerResultReplacesOnlyThatScanner(t *testing.T) {
localCache := &LocalCache{}
localCache.UpdateScanResults([]client.UpdateReportItem{
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
{PackageType: "dnf", PackageName: "kernel", Severity: "critical"},
})
localCache.RecordScannerResult("apt", "success", []client.UpdateReportItem{
{PackageType: "apt", PackageName: "curl", Severity: "moderate"},
}, nil, 250*time.Millisecond, true)
if len(localCache.Updates) != 2 {
t.Fatalf("len(Updates) = %d, want 2", len(localCache.Updates))
}
if got := packageNames(localCache.Updates); got["openssl"] {
t.Fatalf("stale apt package remained in updates: %#v", localCache.Updates)
}
if got := packageNames(localCache.Updates); !got["curl"] || !got["kernel"] {
t.Fatalf("updates = %#v, want curl and kernel", localCache.Updates)
}
if localCache.Summary.ByEcosystem["apt"] != 1 || localCache.Summary.ByEcosystem["dnf"] != 1 {
t.Fatalf("summary by ecosystem = %#v, want apt=1 dnf=1", localCache.Summary.ByEcosystem)
}
if localCache.Scanners["apt"].Status != "success" {
t.Fatalf("scanner status = %q, want success", localCache.Scanners["apt"].Status)
}
}
func TestRecordScannerResultSuccessfulEmptyScanClearsScannerUpdates(t *testing.T) {
localCache := &LocalCache{}
localCache.UpdateScanResults([]client.UpdateReportItem{
{PackageType: "windows_update", PackageName: "KB123", Severity: "important"},
{PackageType: "winget", PackageName: "Git.Git", Severity: "moderate"},
})
localCache.RecordScannerResult("windows", "success", nil, nil, 100*time.Millisecond, true)
if len(localCache.Updates) != 1 {
t.Fatalf("len(Updates) = %d, want 1", len(localCache.Updates))
}
if localCache.Updates[0].PackageType != "winget" {
t.Fatalf("remaining PackageType = %q, want winget", localCache.Updates[0].PackageType)
}
if localCache.Summary.ByEcosystem["windows_update"] != 0 {
t.Fatalf("windows_update summary = %d, want 0", localCache.Summary.ByEcosystem["windows_update"])
}
}
func TestRecordScannerResultFailureDoesNotClearStaleUpdates(t *testing.T) {
localCache := &LocalCache{}
localCache.UpdateScanResults([]client.UpdateReportItem{
{PackageType: "apt", PackageName: "openssl", Severity: "important"},
})
localCache.RecordScannerResult("apt", "failed", nil, errors.New("scanner failed"), time.Second, true)
if len(localCache.Updates) != 1 {
t.Fatalf("len(Updates) = %d, want stale update retained", len(localCache.Updates))
}
if localCache.Scanners["apt"].LastError != "scanner failed" {
t.Fatalf("LastError = %q, want scanner failed", localCache.Scanners["apt"].LastError)
}
}
func TestRecordCapabilityTokenCounts(t *testing.T) {
localCache := &LocalCache{}
localCache.RecordCapabilityTokenFetch(3, nil)
localCache.RecordCapabilityTokenProcess(2, 1)
if localCache.Capabilities.LastFetchedCount != 3 {
t.Fatalf("LastFetchedCount = %d, want 3", localCache.Capabilities.LastFetchedCount)
}
if localCache.Capabilities.LastProcessedCount != 2 {
t.Fatalf("LastProcessedCount = %d, want 2", localCache.Capabilities.LastProcessedCount)
}
if localCache.Capabilities.LastFailedCount != 1 {
t.Fatalf("LastFailedCount = %d, want 1", localCache.Capabilities.LastFailedCount)
}
if localCache.Capabilities.PendingCount != 0 {
t.Fatalf("PendingCount = %d, want 0", localCache.Capabilities.PendingCount)
}
localCache.RecordCapabilityTokenFetch(0, errors.New("server unavailable"))
if localCache.Capabilities.LastError != "server unavailable" {
t.Fatalf("LastError = %q, want server unavailable", localCache.Capabilities.LastError)
}
}
func packageNames(updates []client.UpdateReportItem) map[string]bool {
names := make(map[string]bool, len(updates))
for _, update := range updates {
names[update.PackageName] = true
}
return names
}

View file

@ -0,0 +1,15 @@
package capability
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
)
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
// Split out of token.go so retiring Token does not strand the mutation protocol.
func KeyIDFor(pub ed25519.PublicKey) string {
hash := sha256.Sum256(pub)
return hex.EncodeToString(hash[:16])
}

View file

@ -0,0 +1,423 @@
package capability
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
)
const (
// MutationProtocolVersion belongs to the manifest namespace, independently
// of the current closure-based Token format. Backends opt into the envelope
// path explicitly; pacman begins at the helper boundary.
MutationProtocolVersion = 1
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.
// Derived, not chosen: it is DefaultTokenTTL, the fleet minter's window
// (server/internal/services/capability_minter.go). Standalone mint is
// tighter still at 600s. Doctrine, not a knob — an authority that wants a
// standing capability has to say so by minting again.
MaxAuthorizationLifetimeSeconds = 3600
manifestDomain = "redflag.mutation-manifest"
actionDomain = "redflag.resolved-action"
evidenceDomain = "redflag.evidence"
authorizationDomain = "redflag.mutation-authorization"
receiptDomain = "redflag.mutation-receipt"
)
// ResolvedAction carries exact backend-owned UTF-8 JSON bytes. The common
// protocol signs those bytes but does not reinterpret pacman, WUA, Winget,
// Docker, or self-update semantics into a fictional universal artifact.
type ResolvedAction struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Payload string `json:"payload"`
}
// Evidence identifies provenance or policy evidence by digest. Execution
// location belongs in the resolved action payload, never in this trust class.
type Evidence struct {
Kind string `json:"kind"`
Digest string `json:"digest"`
}
// MutationManifest is the immutable description an authority approves and
// an executor later receives unchanged.
//
// TargetID MUST be the locally provisioned RedFlag agent identity. The generic
// name is deliberate: a later protocol may define another target namespace.
type MutationManifest struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
ResolvedActions []ResolvedAction `json:"resolved_actions"`
Evidence []Evidence `json:"evidence"`
}
// MutationAuthorization binds an authority decision to one manifest and body.
// Every field except Signature is inside CanonicalMessage, including KeyID.
type MutationAuthorization struct {
ProtocolVersion int `json:"protocol_version"`
AuthorizationID string `json:"authorization_id"`
ManifestHash string `json:"manifest_hash"`
AuthorityKind string `json:"authority_kind"`
AuthorityID string `json:"authority_id"`
TargetID string `json:"target_id"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Decision string `json:"decision"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
// MutationEnvelope is the indivisible object handed across an authority or
// executor boundary. Verification always recomputes the manifest hash from the
// manifest carried beside its authorization.
type MutationEnvelope struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
}
func writeLP(buf *bytes.Buffer, value []byte) {
buf.WriteString(strconv.Itoa(len(value)))
buf.WriteByte(':')
buf.Write(value)
}
func canonicalRecord(domain string, values ...string) []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(domain))
for _, value := range values {
writeLP(&buf, []byte(value))
}
return buf.Bytes()
}
func (a ResolvedAction) canonicalBytes() []byte {
return canonicalRecord(actionDomain, a.Kind, a.Identity, a.Payload)
}
func (e Evidence) canonicalBytes() []byte {
return canonicalRecord(evidenceDomain, e.Kind, e.Digest)
}
func sortedRecords[T any](values []T, encode func(T) []byte) [][]byte {
records := make([][]byte, 0, len(values))
for _, value := range values {
records = append(records, encode(value))
}
sort.Slice(records, func(i, j int) bool { return bytes.Compare(records[i], records[j]) < 0 })
return records
}
// CanonicalBytes is domain-separated and length-prefixed. Action and evidence
// ordering is irrelevant, while exact duplicates remain present and therefore
// change the hash. No set conversion is permitted here.
func (m MutationManifest) CanonicalBytes() []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(manifestDomain))
for _, value := range []string{
strconv.Itoa(m.ProtocolVersion),
m.OperationID,
m.TargetID,
m.Backend,
m.Operation,
} {
writeLP(&buf, []byte(value))
}
actions := sortedRecords(m.ResolvedActions, func(a ResolvedAction) []byte { return a.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(actions))))
for _, action := range actions {
writeLP(&buf, action)
}
evidence := sortedRecords(m.Evidence, func(e Evidence) []byte { return e.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(evidence))))
for _, item := range evidence {
writeLP(&buf, item)
}
return buf.Bytes()
}
func (m MutationManifest) Hash() string {
digest := sha256.Sum256(m.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
func (m MutationManifest) Validate() error {
if m.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported manifest version %d", m.ProtocolVersion)
}
for _, field := range [][2]string{
{"operation_id", m.OperationID},
{"target_id", m.TargetID},
{"backend", m.Backend},
{"operation", m.Operation},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: manifest %s is empty", name)
}
}
if len(m.ResolvedActions) == 0 {
return fmt.Errorf("mutation protocol: manifest has no resolved actions")
}
for i, action := range m.ResolvedActions {
if action.Kind == "" || action.Identity == "" || action.Payload == "" {
return fmt.Errorf("mutation protocol: resolved action %d is incomplete", i)
}
if !json.Valid([]byte(action.Payload)) {
return fmt.Errorf("mutation protocol: resolved action %d payload is not JSON", i)
}
}
for i, evidence := range m.Evidence {
if evidence.Kind == "" {
return fmt.Errorf("mutation protocol: evidence %d kind is empty", i)
}
decoded, err := hex.DecodeString(evidence.Digest)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("mutation protocol: evidence %d digest is not SHA-256 hex", i)
}
}
return nil
}
func (a MutationAuthorization) CanonicalMessage() []byte {
return canonicalRecord(
authorizationDomain,
strconv.Itoa(a.ProtocolVersion),
a.AuthorizationID,
a.ManifestHash,
a.AuthorityKind,
a.AuthorityID,
a.TargetID,
strconv.FormatInt(a.IssuedAt, 10),
strconv.FormatInt(a.NotBefore, 10),
strconv.FormatInt(a.ExpiresAt, 10),
a.Decision,
a.KeyID,
)
}
// IsCanonicalUUIDv4 reports whether s is 8-4-4-4-12 lowercase hex with the
// version (4) and variant (8/9/a/b) nibbles set. Same discipline the standalone
// mint already applies to request_id, applied here before authorization_id can
// become an executor replay key: a newline-delimited replay ledger matched by
// exact line has no defence against an identifier that contains a newline.
func IsCanonicalUUIDv4(s string) bool {
if len(s) != 36 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
continue
}
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
if !isHex {
return false
}
}
if s[14] != '4' {
return false
}
switch s[19] {
case '8', '9', 'a', 'b':
return true
}
return false
}
func (a MutationAuthorization) validateShape() error {
if a.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
}
for _, field := range [][2]string{
{"authorization_id", a.AuthorizationID},
{"authority_kind", a.AuthorityKind},
{"authority_id", a.AuthorityID},
{"target_id", a.TargetID},
{"decision", a.Decision},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
}
}
if !IsCanonicalUUIDv4(a.AuthorizationID) {
return fmt.Errorf("mutation protocol: authorization_id is not a canonical UUID v4")
}
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
return fmt.Errorf("mutation protocol: invalid authorization time window")
}
if a.ExpiresAt-a.NotBefore > MaxAuthorizationLifetimeSeconds {
return fmt.Errorf("mutation protocol: authorization lifetime %ds exceeds the %ds ceiling",
a.ExpiresAt-a.NotBefore, MaxAuthorizationLifetimeSeconds)
}
return nil
}
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
if len(priv) != ed25519.PrivateKeySize {
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
a.ManifestHash = manifest.Hash()
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
return nil
}
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
if a.ManifestHash != manifest.Hash() {
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
}
if a.KeyID != KeyIDFor(pub) {
return fmt.Errorf("mutation protocol: authorization key id mismatch")
}
signature, err := hex.DecodeString(a.Signature)
if err != nil || len(signature) != ed25519.SignatureSize {
return fmt.Errorf("mutation protocol: malformed signature")
}
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
return fmt.Errorf("mutation protocol: signature verification failed")
}
return nil
}
// VerifyForExecutionAt adds the executor's decision and time checks to the
// cryptographic envelope verification.
func (a MutationAuthorization) VerifyForExecutionAt(pub ed25519.PublicKey, manifest MutationManifest, now int64) error {
if err := a.Verify(pub, manifest); err != nil {
return err
}
if a.Decision != "allow" {
return fmt.Errorf("mutation protocol: authorization decision is %q", a.Decision)
}
if now < a.NotBefore || now > a.ExpiresAt {
return fmt.Errorf("mutation protocol: authorization is outside its time window")
}
return nil
}
func (e MutationEnvelope) VerifyForExecutionAt(pub ed25519.PublicKey, now int64) error {
return e.Authorization.VerifyForExecutionAt(pub, e.Manifest, now)
}
// MutationReceipt is the response half of the contract: what the privileged
// executor did with one envelope. It carries the audit join ARCH-002 names —
// operation ID, manifest hash, authorization ID — so a local receipt and a
// server history row can be joined without either guessing.
//
// It is not signed. The executor is not a second authority; this is a record
// produced inside the trust boundary that already ran the operation. Decision
// and Reason keep the PolicyResult taxonomy rather than inventing a new one.
type MutationReceipt struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
ManifestHash string `json:"manifest_hash"`
AuthorizationID string `json:"authorization_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
Decision string `json:"decision"` // executed | denied | failed
Reason string `json:"reason"`
Executed bool `json:"executed"`
VerifiedActions int `json:"verified_actions"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// CanonicalBytes pins the receipt the same way the manifest is pinned, so a
// ledger can digest one without re-deriving field order from JSON. Every field
// is present even when empty — a refusal before parse still produces a receipt,
// and its emptiness is part of the record.
func (r MutationReceipt) CanonicalBytes() []byte {
return canonicalRecord(
receiptDomain,
strconv.Itoa(r.ProtocolVersion),
r.OperationID,
r.ManifestHash,
r.AuthorizationID,
r.TargetID,
r.Backend,
r.Operation,
r.Decision,
r.Reason,
strconv.FormatBool(r.Executed),
strconv.Itoa(r.VerifiedActions),
strconv.Itoa(r.ExitCode),
r.Error,
strconv.FormatInt(r.Timestamp, 10),
)
}
func (r MutationReceipt) Digest() string {
digest := sha256.Sum256(r.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
// MutationOutcome is what the executor did, separate from which envelope it
// did it to.
type MutationOutcome struct {
Decision string
Reason string
Executed bool
VerifiedActions int
ExitCode int
Detail string
}
// ReceiptFor copies the audit join out of signed bytes rather than retyping it.
func (e MutationEnvelope) ReceiptFor(outcome MutationOutcome, now int64) MutationReceipt {
return MutationReceipt{
ProtocolVersion: MutationProtocolVersion,
OperationID: e.Manifest.OperationID,
ManifestHash: e.Manifest.Hash(),
AuthorizationID: e.Authorization.AuthorizationID,
TargetID: e.Manifest.TargetID,
Backend: e.Manifest.Backend,
Operation: e.Manifest.Operation,
Decision: outcome.Decision,
Reason: outcome.Reason,
Executed: outcome.Executed,
VerifiedActions: outcome.VerifiedActions,
ExitCode: outcome.ExitCode,
Error: outcome.Detail,
Timestamp: now,
}
}

View file

@ -0,0 +1,302 @@
package capability
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)
type mutationProtocolFixture struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
Receipt MutationReceipt `json:"receipt"`
TestSeed string `json:"test_seed"`
ExpectedManifestCanonicalHex string `json:"expected_manifest_canonical_hex"`
ExpectedManifestHash string `json:"expected_manifest_hash"`
ExpectedAuthorizationCanonical string `json:"expected_authorization_canonical_hex"`
ExpectedKeyID string `json:"expected_key_id"`
ExpectedSignature string `json:"expected_signature"`
ExpectedReceiptCanonicalHex string `json:"expected_receipt_canonical_hex"`
ExpectedReceiptDigest string `json:"expected_receipt_digest"`
}
func loadMutationProtocolFixture(t *testing.T) mutationProtocolFixture {
t.Helper()
raw, err := os.ReadFile("../../../protocol/testdata/mutation-golden.json")
if err != nil {
t.Fatal(err)
}
var fixture mutationProtocolFixture
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatal(err)
}
return fixture
}
func signedMutationProtocolFixture(t *testing.T) (MutationManifest, MutationAuthorization, ed25519.PublicKey) {
t.Helper()
fixture := loadMutationProtocolFixture(t)
seed, err := hex.DecodeString(fixture.TestSeed)
if err != nil {
t.Fatal(err)
}
privateKey := ed25519.NewKeyFromSeed(seed)
authorization := fixture.Authorization
if err := authorization.Sign(privateKey, fixture.Manifest); err != nil {
t.Fatal(err)
}
return fixture.Manifest, authorization, privateKey.Public().(ed25519.PublicKey)
}
func cloneMutationManifest(t *testing.T, manifest MutationManifest) MutationManifest {
t.Helper()
raw, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
var clone MutationManifest
if err := json.Unmarshal(raw, &clone); err != nil {
t.Fatal(err)
}
return clone
}
func TestMutationProtocolGoldenVector(t *testing.T) {
fixture := loadMutationProtocolFixture(t)
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
if fixture.ExpectedManifestHash == "" {
t.Fatalf(
"fill fixture: manifest_canonical=%s\nmanifest_hash=%s\nauthorization_canonical=%s\nkey_id=%s\nsignature=%s\nreceipt_canonical=%s\nreceipt_digest=%s",
hex.EncodeToString(manifest.CanonicalBytes()),
manifest.Hash(),
hex.EncodeToString(authorization.CanonicalMessage()),
authorization.KeyID,
authorization.Signature,
hex.EncodeToString(fixture.Receipt.CanonicalBytes()),
fixture.Receipt.Digest(),
)
}
if got := hex.EncodeToString(manifest.CanonicalBytes()); got != fixture.ExpectedManifestCanonicalHex {
t.Fatalf("manifest canonical bytes = %q, want %q", got, fixture.ExpectedManifestCanonicalHex)
}
if got := manifest.Hash(); got != fixture.ExpectedManifestHash {
t.Fatalf("manifest hash = %q, want %q", got, fixture.ExpectedManifestHash)
}
if got := hex.EncodeToString(authorization.CanonicalMessage()); got != fixture.ExpectedAuthorizationCanonical {
t.Fatalf("authorization canonical bytes = %q, want %q", got, fixture.ExpectedAuthorizationCanonical)
}
if authorization.KeyID != fixture.ExpectedKeyID {
t.Fatalf("key id = %q, want %q", authorization.KeyID, fixture.ExpectedKeyID)
}
if authorization.Signature != fixture.ExpectedSignature {
t.Fatalf("signature = %q, want %q", authorization.Signature, fixture.ExpectedSignature)
}
if got := hex.EncodeToString(fixture.Receipt.CanonicalBytes()); got != fixture.ExpectedReceiptCanonicalHex {
t.Fatalf("receipt canonical bytes = %q, want %q", got, fixture.ExpectedReceiptCanonicalHex)
}
if got := fixture.Receipt.Digest(); got != fixture.ExpectedReceiptDigest {
t.Fatalf("receipt digest = %q, want %q", got, fixture.ExpectedReceiptDigest)
}
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
if err := envelope.VerifyForExecutionAt(publicKey, 1_700_000_100); err != nil {
t.Fatalf("golden authorization did not verify: %v", err)
}
}
func TestMutationProtocolOrderingAndDuplicateSemantics(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
reordered := cloneMutationManifest(t, manifest)
reordered.ResolvedActions[0], reordered.ResolvedActions[1] = reordered.ResolvedActions[1], reordered.ResolvedActions[0]
reordered.Evidence[0], reordered.Evidence[1] = reordered.Evidence[1], reordered.Evidence[0]
if reordered.Hash() != manifest.Hash() {
t.Fatal("manifest hash changed when action/evidence order changed")
}
if err := authorization.Verify(publicKey, reordered); err != nil {
t.Fatalf("authorization rejected reordered manifest: %v", err)
}
duplicate := cloneMutationManifest(t, manifest)
duplicate.ResolvedActions = append(duplicate.ResolvedActions, duplicate.ResolvedActions[0])
if duplicate.Hash() == manifest.Hash() {
t.Fatal("exact duplicate action was silently de-duplicated")
}
if err := authorization.Verify(publicKey, duplicate); err == nil {
t.Fatal("authorization accepted a duplicate resolved action")
}
}
func TestMutationProtocolExecutorAffectingTamperFails(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
tests := map[string]func(*MutationManifest){
"provenance": func(m *MutationManifest) { m.Evidence[0].Digest = strings.Repeat("c", 64) },
"execution location": func(m *MutationManifest) {
m.ResolvedActions[0].Payload = strings.Replace(m.ResolvedActions[0].Payload, "/var/cache/redflag", "/tmp", 1)
},
"target": func(m *MutationManifest) { m.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211" },
"backend": func(m *MutationManifest) { m.Backend = "wua" },
"resolved action": func(m *MutationManifest) { m.ResolvedActions[0].Identity = "zsh@6.0-1" },
}
for name, tamper := range tests {
t.Run(name, func(t *testing.T) {
changed := cloneMutationManifest(t, manifest)
tamper(&changed)
if err := authorization.Verify(publicKey, changed); err == nil {
t.Fatal("authorization accepted tampered manifest")
}
})
}
changedAuthorization := authorization
changedAuthorization.IssuedAt++
if err := changedAuthorization.Verify(publicKey, manifest); err == nil {
t.Fatal("authorization accepted tampered authorization metadata")
}
}
func TestMutationProtocolUnknownVersionsFailClosed(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
manifest.ProtocolVersion++
if err := manifest.Validate(); err == nil {
t.Fatal("unknown manifest version passed validation")
}
manifest.ProtocolVersion = MutationProtocolVersion
authorization.ProtocolVersion++
if err := authorization.Verify(publicKey, manifest); err == nil {
t.Fatal("unknown authorization version passed verification")
}
}
// The target fields carry the RedFlag agent identity. Both are signed and the
// verifier requires them equal, so an executor that binds either one to the
// host it read for itself has bound the whole envelope.
func TestMutationProtocolTargetBindsManifestAndAuthorization(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
if manifest.TargetID != authorization.TargetID {
t.Fatal("golden fixture disagrees with itself about the target")
}
split := authorization
split.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211"
if err := split.Verify(publicKey, manifest); err == nil {
t.Fatal("authorization for another target verified against this manifest")
}
}
func TestMutationAuthorizationIDIsCanonicalUUIDv4(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
// A replay ledger matched line-by-line has no defence against an embedded
// newline; the shape check is what makes the identifier safe to record.
for _, bad := range []string{
"",
"not-a-uuid",
"550e8400-e29b-41d4-a716-44665544001",
"550e8400-e29b-11d4-a716-446655440011",
"550e8400-e29b-41d4-c716-446655440011",
"550E8400-E29B-41D4-A716-446655440011",
"550e8400-e29b-41d4-a716-4466554400\n1",
} {
if IsCanonicalUUIDv4(bad) {
t.Fatalf("accepted %q as a canonical UUID v4", bad)
}
changed := authorization
changed.AuthorizationID = bad
if err := changed.Verify(publicKey, manifest); err == nil {
t.Fatalf("authorization with id %q verified", bad)
}
}
if !IsCanonicalUUIDv4(authorization.AuthorizationID) {
t.Fatalf("golden authorization_id %q is not a canonical UUID v4", authorization.AuthorizationID)
}
}
func TestMutationAuthorizationLifetimeCeiling(t *testing.T) {
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
seed, err := hex.DecodeString(loadMutationProtocolFixture(t).TestSeed)
if err != nil {
t.Fatal(err)
}
privateKey := ed25519.NewKeyFromSeed(seed)
atCeiling := authorization
atCeiling.ExpiresAt = atCeiling.NotBefore + MaxAuthorizationLifetimeSeconds
if err := atCeiling.Sign(privateKey, manifest); err != nil {
t.Fatalf("an authorization exactly at the ceiling must sign: %v", err)
}
if err := atCeiling.Verify(publicKey, manifest); err != nil {
t.Fatalf("an authorization exactly at the ceiling must verify: %v", err)
}
overCeiling := authorization
overCeiling.ExpiresAt = overCeiling.NotBefore + MaxAuthorizationLifetimeSeconds + 1
if err := overCeiling.Sign(privateKey, manifest); err == nil {
t.Fatal("minted an authorization past the lifetime ceiling")
}
if err := overCeiling.Verify(publicKey, manifest); err == nil {
t.Fatal("verified an authorization past the lifetime ceiling")
}
}
// Evidence carries digests. Operator reason prose and policy text stay in the
// authority's journal, and the shape check is what keeps them out.
func TestMutationEvidenceCarriesDigestsNotProse(t *testing.T) {
manifest, _, _ := signedMutationProtocolFixture(t)
for _, bad := range []string{"", "operator accepted the CVE risk", strings.Repeat("a", 63), strings.Repeat("z", 64)} {
changed := cloneMutationManifest(t, manifest)
changed.Evidence[0].Digest = bad
if err := changed.Validate(); err == nil {
t.Fatalf("manifest validated with evidence digest %q", bad)
}
}
}
func TestMutationReceiptCarriesAuditJoin(t *testing.T) {
manifest, authorization, _ := signedMutationProtocolFixture(t)
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
receipt := envelope.ReceiptFor(MutationOutcome{
Decision: "denied",
Reason: "backend_not_migrated",
ExitCode: 18,
Detail: "backend=pacman",
}, 1_700_000_100)
if receipt.OperationID != manifest.OperationID ||
receipt.ManifestHash != manifest.Hash() ||
receipt.AuthorizationID != authorization.AuthorizationID ||
receipt.TargetID != manifest.TargetID {
t.Fatal("receipt lost the operation/manifest/authorization/target join")
}
if receipt.Backend != manifest.Backend || receipt.Operation != manifest.Operation {
t.Fatal("receipt lost the backend/operation it answers")
}
// Every recorded field is in the canonical bytes, including the ones a
// lossy report would drop first.
for name, mutate := range map[string]func(*MutationReceipt){
"decision": func(r *MutationReceipt) { r.Decision = "executed" },
"reason": func(r *MutationReceipt) { r.Reason = "operation_completed" },
"executed": func(r *MutationReceipt) { r.Executed = true },
"verified actions": func(r *MutationReceipt) { r.VerifiedActions = 1 },
"exit code": func(r *MutationReceipt) { r.ExitCode = 0 },
"error": func(r *MutationReceipt) { r.Error = "" },
"timestamp": func(r *MutationReceipt) { r.Timestamp++ },
} {
t.Run(name, func(t *testing.T) {
changed := receipt
mutate(&changed)
if changed.Digest() == receipt.Digest() {
t.Fatal("receipt digest ignored a recorded field")
}
})
}
}

View file

@ -0,0 +1,110 @@
// Package capability defines the supply-chain capability token: an Ed25519-signed
// authorization for exactly one package operation over a fully-resolved dependency
// closure. The server (authority) mints and signs tokens; the agent passes them to
// the privileged Rust executor (helper/) which independently verifies them.
//
// The canonical signed message and closure hash MUST stay byte-identical across
// this package, the server's mirror of it, and helper/src/main.rs. See
// RAF/security/05-supply-chain-gate.md for the contract.
package capability
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
)
// Version is the only token format this code understands. Forward-only doctrine:
// new versions add fields, never reinterpret existing ones.
const Version = 1
// ClosureEntry is one resolved artifact in the dependency closure.
type ClosureEntry struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Source string `json:"source"` // "mirror" | "registry"
ArtifactPath string `json:"artifact_path,omitempty"` // local path or url, optional
}
// Token is the full capability token exchanged between server, agent, and executor.
type Token struct {
Version int `json:"version"`
TokenID string `json:"token_id"`
AgentID string `json:"agent_id"`
KeyID string `json:"key_id"`
PackageType string `json:"package_type"` // apt|dnf|npm|bun|pip|docker|winget
Operation string `json:"operation"` // install|upgrade (forward-only)
Closure []ClosureEntry `json:"closure"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Signature string `json:"signature"` // hex ed25519
}
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
func (t *Token) ClosureHash() string {
set := make(map[string]struct{}, len(t.Closure))
for _, e := range t.Closure {
set[fmt.Sprintf("%s@%s#%s", e.Name, e.Version, e.SHA256)] = struct{}{}
}
lines := make([]string, 0, len(set))
for line := range set {
lines = append(lines, line)
}
sort.Strings(lines)
h := sha256.New()
for i, line := range lines {
if i > 0 {
h.Write([]byte("\n"))
}
h.Write([]byte(line))
}
return hex.EncodeToString(h.Sum(nil))
}
// CanonicalMessage builds the deterministic message that is signed/verified:
// "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}".
func (t *Token) CanonicalMessage() string {
return fmt.Sprintf("%s:%s:%s:%s:%s:%d",
t.AgentID, t.TokenID, t.Operation, t.PackageType, t.ClosureHash(), t.ExpiresAt)
}
// Sign signs the canonical message with the authority private key, sets the
// token's KeyID and Signature, and returns the hex signature.
func (t *Token) Sign(priv ed25519.PrivateKey) (string, error) {
if len(priv) != ed25519.PrivateKeySize {
return "", fmt.Errorf("capability: invalid private key size %d", len(priv))
}
pub := priv.Public().(ed25519.PublicKey)
t.KeyID = KeyIDFor(pub)
sig := ed25519.Sign(priv, []byte(t.CanonicalMessage()))
t.Signature = hex.EncodeToString(sig)
return t.Signature, nil
}
// Verify checks the token's signature against the given public key. It does not
// check the validity window, agent binding, or artifact hashes — those are the
// executor's responsibility (and the agent's bind-check). It verifies only that
// this key signed this canonical message.
func (t *Token) Verify(pub ed25519.PublicKey) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("capability: invalid public key size %d", len(pub))
}
sig, err := hex.DecodeString(t.Signature)
if err != nil {
return fmt.Errorf("capability: signature not hex: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("capability: invalid signature size %d", len(sig))
}
if !ed25519.Verify(pub, []byte(t.CanonicalMessage()), sig) {
return fmt.Errorf("capability: signature verification failed")
}
return nil
}

View file

@ -0,0 +1,63 @@
package capability
import (
"crypto/ed25519"
"testing"
)
// Cross-language contract vector. The Rust executor (helper/src/main.rs) asserts
// these same strings for the same input. If either side drifts, both break.
func TestCanonicalVector(t *testing.T) {
tok := &Token{
Version: 1, TokenID: "tok-1", AgentID: "agent-123",
PackageType: "npm", Operation: "install",
Closure: []ClosureEntry{
{Name: "left-pad", Version: "1.3.0", SHA256: "aaaa"},
{Name: "is-odd", Version: "2.0.0", SHA256: "bbbb"},
},
ExpiresAt: 1700000000,
}
const wantHash = "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f"
const wantMsg = "agent-123:tok-1:install:npm:" + wantHash + ":1700000000"
if got := tok.ClosureHash(); got != wantHash {
t.Fatalf("ClosureHash() = %q, want %q", got, wantHash)
}
if got := tok.CanonicalMessage(); got != wantMsg {
t.Fatalf("CanonicalMessage() = %q, want %q", got, wantMsg)
}
}
func TestClosureHashOrderIndependent(t *testing.T) {
a := &Token{Closure: []ClosureEntry{{Name: "a", Version: "1", SHA256: "x"}, {Name: "b", Version: "2", SHA256: "y"}}}
b := &Token{Closure: []ClosureEntry{{Name: "b", Version: "2", SHA256: "y"}, {Name: "a", Version: "1", SHA256: "x"}}}
if a.ClosureHash() != b.ClosureHash() {
t.Fatalf("closure hash depends on order: %q != %q", a.ClosureHash(), b.ClosureHash())
}
}
func TestSignVerifyRoundtrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
tok := &Token{
Version: 1, TokenID: "tok-2", AgentID: "agent-9",
PackageType: "dnf", Operation: "upgrade",
Closure: []ClosureEntry{{Name: "openssl", Version: "3.2.1", SHA256: "deadbeef"}},
ExpiresAt: 1700000000,
}
if _, err := tok.Sign(priv); err != nil {
t.Fatal(err)
}
if tok.KeyID != KeyIDFor(pub) {
t.Fatalf("KeyID = %q, want %q", tok.KeyID, KeyIDFor(pub))
}
if err := tok.Verify(pub); err != nil {
t.Fatalf("Verify after Sign failed: %v", err)
}
// Tamper detection: any change to the closure breaks verification.
tok.Closure[0].Version = "3.2.2"
if err := tok.Verify(pub); err == nil {
t.Fatal("Verify accepted a tampered closure")
}
}

View file

@ -0,0 +1,233 @@
package circuitbreaker
import (
"fmt"
"sync"
"time"
)
// State represents the circuit breaker state
type State int
const (
StateClosed State = iota // Normal operation
StateOpen // Circuit is open, failing fast
StateHalfOpen // Testing if service recovered
)
func (s State) String() string {
switch s {
case StateClosed:
return "closed"
case StateOpen:
return "open"
case StateHalfOpen:
return "half-open"
default:
return "unknown"
}
}
// Config holds circuit breaker configuration
type Config struct {
FailureThreshold int // Number of failures before opening
FailureWindow time.Duration // Time window to track failures
OpenDuration time.Duration // How long circuit stays open
HalfOpenAttempts int // Successful attempts needed to close from half-open
}
// CircuitBreaker implements the circuit breaker pattern for subsystems
type CircuitBreaker struct {
name string
config Config
mu sync.RWMutex
state State
failures []time.Time // Timestamps of recent failures
consecutiveSuccess int // Consecutive successes in half-open state
openedAt time.Time // When circuit was opened
}
// New creates a new circuit breaker
func New(name string, config Config) *CircuitBreaker {
return &CircuitBreaker{
name: name,
config: config,
state: StateClosed,
failures: make([]time.Time, 0),
}
}
// Call executes the given function with circuit breaker protection
func (cb *CircuitBreaker) Call(fn func() error) error {
// Check if we can execute
if err := cb.beforeCall(); err != nil {
return err
}
// Execute the function
err := fn()
// Record the result
cb.afterCall(err)
return err
}
// beforeCall checks if the call should be allowed
func (cb *CircuitBreaker) beforeCall() error {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateClosed:
// Normal operation, allow call
return nil
case StateOpen:
// Check if enough time has passed to try half-open
if time.Since(cb.openedAt) >= cb.config.OpenDuration {
cb.state = StateHalfOpen
cb.consecutiveSuccess = 0
return nil
}
// Circuit is still open, fail fast
return fmt.Errorf("circuit breaker [%s] is OPEN (will retry at %s)",
cb.name, cb.openedAt.Add(cb.config.OpenDuration).Format("15:04:05"))
case StateHalfOpen:
// In half-open state, allow limited attempts
return nil
default:
return fmt.Errorf("unknown circuit breaker state")
}
}
// afterCall records the result and updates state
func (cb *CircuitBreaker) afterCall(err error) {
cb.mu.Lock()
defer cb.mu.Unlock()
now := time.Now()
if err != nil {
// Record failure
cb.recordFailure(now)
// If in half-open, go back to open on any failure
if cb.state == StateHalfOpen {
cb.state = StateOpen
cb.openedAt = now
cb.consecutiveSuccess = 0
return
}
// Check if we should open the circuit
if cb.shouldOpen(now) {
cb.state = StateOpen
cb.openedAt = now
cb.consecutiveSuccess = 0
}
} else {
// Success
switch cb.state {
case StateHalfOpen:
// Count consecutive successes
cb.consecutiveSuccess++
if cb.consecutiveSuccess >= cb.config.HalfOpenAttempts {
// Enough successes, close the circuit
cb.state = StateClosed
cb.failures = make([]time.Time, 0)
cb.consecutiveSuccess = 0
}
case StateClosed:
// Clean up old failures on success
cb.cleanupOldFailures(now)
}
}
}
// recordFailure adds a failure timestamp
func (cb *CircuitBreaker) recordFailure(now time.Time) {
cb.failures = append(cb.failures, now)
cb.cleanupOldFailures(now)
}
// cleanupOldFailures removes failures outside the window
func (cb *CircuitBreaker) cleanupOldFailures(now time.Time) {
cutoff := now.Add(-cb.config.FailureWindow)
validFailures := make([]time.Time, 0)
for _, failTime := range cb.failures {
if failTime.After(cutoff) {
validFailures = append(validFailures, failTime)
}
}
cb.failures = validFailures
}
// shouldOpen determines if circuit should open based on failures
func (cb *CircuitBreaker) shouldOpen(now time.Time) bool {
cb.cleanupOldFailures(now)
return len(cb.failures) >= cb.config.FailureThreshold
}
// State returns the current circuit breaker state (thread-safe)
func (cb *CircuitBreaker) State() State {
cb.mu.RLock()
defer cb.mu.RUnlock()
return cb.state
}
// GetStats returns current circuit breaker statistics
func (cb *CircuitBreaker) GetStats() Stats {
cb.mu.RLock()
defer cb.mu.RUnlock()
stats := Stats{
Name: cb.name,
State: cb.state.String(),
RecentFailures: len(cb.failures),
ConsecutiveSuccess: cb.consecutiveSuccess,
}
if cb.state == StateOpen && !cb.openedAt.IsZero() {
nextAttempt := cb.openedAt.Add(cb.config.OpenDuration)
stats.NextAttempt = &nextAttempt
}
return stats
}
// Reset manually resets the circuit breaker to closed state
func (cb *CircuitBreaker) Reset() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.state = StateClosed
cb.failures = make([]time.Time, 0)
cb.consecutiveSuccess = 0
cb.openedAt = time.Time{}
}
// Stats holds circuit breaker statistics
type Stats struct {
Name string
State string
RecentFailures int
ConsecutiveSuccess int
NextAttempt *time.Time
}
// String returns a human-readable representation of the stats
func (s Stats) String() string {
if s.NextAttempt != nil {
return fmt.Sprintf("[%s] state=%s, failures=%d, next_attempt=%s",
s.Name, s.State, s.RecentFailures, s.NextAttempt.Format("15:04:05"))
}
return fmt.Sprintf("[%s] state=%s, failures=%d, success=%d",
s.Name, s.State, s.RecentFailures, s.ConsecutiveSuccess)
}

View file

@ -0,0 +1,138 @@
package circuitbreaker
import (
"errors"
"testing"
"time"
)
func TestCircuitBreaker_NormalOperation(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 3,
FailureWindow: 1 * time.Minute,
OpenDuration: 1 * time.Minute,
HalfOpenAttempts: 2,
})
// Should allow calls in closed state
err := cb.Call(func() error { return nil })
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if cb.State() != StateClosed {
t.Fatalf("expected state closed, got %v", cb.State())
}
}
func TestCircuitBreaker_OpensAfterFailures(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 3,
FailureWindow: 1 * time.Minute,
OpenDuration: 100 * time.Millisecond,
HalfOpenAttempts: 2,
})
testErr := errors.New("test error")
// Record 3 failures
for i := 0; i < 3; i++ {
cb.Call(func() error { return testErr })
}
// Should now be open
if cb.State() != StateOpen {
t.Fatalf("expected state open after %d failures, got %v", 3, cb.State())
}
// Next call should fail fast
err := cb.Call(func() error { return nil })
if err == nil {
t.Fatal("expected circuit breaker to reject call, but it succeeded")
}
}
func TestCircuitBreaker_HalfOpenRecovery(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 2,
FailureWindow: 1 * time.Minute,
OpenDuration: 50 * time.Millisecond,
HalfOpenAttempts: 2,
})
testErr := errors.New("test error")
// Open the circuit
cb.Call(func() error { return testErr })
cb.Call(func() error { return testErr })
if cb.State() != StateOpen {
t.Fatal("circuit should be open")
}
// Wait for open duration
time.Sleep(60 * time.Millisecond)
// Should transition to half-open and allow call
err := cb.Call(func() error { return nil })
if err != nil {
t.Fatalf("expected call to succeed in half-open state, got %v", err)
}
if cb.State() != StateHalfOpen {
t.Fatalf("expected half-open state, got %v", cb.State())
}
// One more success should close it
cb.Call(func() error { return nil })
if cb.State() != StateClosed {
t.Fatalf("expected closed state after %d successes, got %v", 2, cb.State())
}
}
func TestCircuitBreaker_HalfOpenFailure(t *testing.T) {
cb := New("test", Config{
FailureThreshold: 2,
FailureWindow: 1 * time.Minute,
OpenDuration: 50 * time.Millisecond,
HalfOpenAttempts: 2,
})
testErr := errors.New("test error")
// Open the circuit
cb.Call(func() error { return testErr })
cb.Call(func() error { return testErr })
// Wait and attempt in half-open
time.Sleep(60 * time.Millisecond)
cb.Call(func() error { return nil }) // Half-open
// Fail in half-open - should go back to open
cb.Call(func() error { return testErr })
if cb.State() != StateOpen {
t.Fatalf("expected open state after half-open failure, got %v", cb.State())
}
}
func TestCircuitBreaker_Stats(t *testing.T) {
cb := New("test-subsystem", Config{
FailureThreshold: 3,
FailureWindow: 1 * time.Minute,
OpenDuration: 1 * time.Minute,
HalfOpenAttempts: 2,
})
stats := cb.GetStats()
if stats.Name != "test-subsystem" {
t.Fatalf("expected name 'test-subsystem', got %s", stats.Name)
}
if stats.State != "closed" {
t.Fatalf("expected state 'closed', got %s", stats.State)
}
if stats.RecentFailures != 0 {
t.Fatalf("expected 0 failures, got %d", stats.RecentFailures)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,31 @@
package client
import "time"
// InventoryItem represents a single inventory record -- something present on
// the system. Distinct from UpdateReportItem (which represents an available
// update) and MetricsReportItem (which represents a point-in-time measurement).
//
// Inspired by osquery's per-ecosystem table model (rpm_packages, deb_packages,
// programs, docker_images) where each item has typed, indexed fields rather
// than a freeform Metadata map.
type InventoryItem struct {
Ecosystem string `json:"inventory_ecosystem"` // "docker", "system", "apt", "dnf", etc.
ItemName string `json:"item_name"` // Primary identifier within ecosystem
ItemVersion string `json:"item_version"` // Installed version / digest
Description string `json:"description,omitempty"`
Arch string `json:"arch,omitempty"`
InstallTime string `json:"install_time,omitempty"` // RFC3339 or ecosystem-specific
SizeBytes int64 `json:"size_bytes,omitempty"`
Vendor string `json:"vendor,omitempty"` // Registry, repo, manufacturer
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// InventoryReport is sent by agents when reporting inventory data.
type InventoryReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Ecosystem string `json:"inventory_ecosystem"`
Items []InventoryItem `json:"items"`
ScanSucceeded bool `json:"scan_succeeded"`
}

View file

@ -0,0 +1,77 @@
package client
// machine_id_logging_test.go — Pre-fix tests for machine ID logging format.
//
// F-D1-5 LOW: client.go:39 uses fmt.Printf instead of log.Printf.
//
// Run: cd agent && go test ./internal/client/... -v -run TestClientMachineID
import (
"os"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// Test 6.1 — Documents fmt.Printf usage (F-D1-5)
//
// Category: PASS-NOW (documents ETHOS violation)
// ---------------------------------------------------------------------------
func TestClientMachineIDErrorUsesFmtPrintf(t *testing.T) {
// POST-FIX (F-D1-5): fmt.Printf replaced with log.Printf.
content, err := os.ReadFile("client.go")
if err != nil {
t.Fatalf("failed to read client.go: %v", err)
}
src := string(content)
newClientIdx := strings.Index(src, "func NewClient(")
if newClientIdx == -1 {
t.Fatal("[ERROR] [agent] [client] NewClient function not found")
}
fnBody := src[newClientIdx:]
nextFn := strings.Index(fnBody[1:], "\nfunc ")
if nextFn > 0 {
fnBody = fnBody[:nextFn+1]
}
if strings.Contains(fnBody, "fmt.Printf") {
t.Error("[ERROR] [agent] [client] F-D1-5 NOT FIXED: fmt.Printf still in NewClient")
}
t.Log("[INFO] [agent] [client] F-D1-5 FIXED: structured logging in NewClient")
}
// ---------------------------------------------------------------------------
// Test 6.2 — Must use structured logging (assert fix)
//
// Category: FAIL-NOW / PASS-AFTER-FIX
// ---------------------------------------------------------------------------
func TestClientMachineIDErrorUsesStructuredLogging(t *testing.T) {
content, err := os.ReadFile("client.go")
if err != nil {
t.Fatalf("failed to read client.go: %v", err)
}
src := string(content)
newClientIdx := strings.Index(src, "func NewClient(")
if newClientIdx == -1 {
t.Fatal("[ERROR] [agent] [client] NewClient function not found")
}
fnBody := src[newClientIdx:]
nextFn := strings.Index(fnBody[1:], "\nfunc ")
if nextFn > 0 {
fnBody = fnBody[:nextFn+1]
}
if strings.Contains(fnBody, "fmt.Printf") {
t.Errorf("[ERROR] [agent] [client] NewClient uses fmt.Printf for machine ID error.\n" +
"F-D1-5: use log.Printf with [WARNING] [agent] [client] format.")
}
}

View file

@ -0,0 +1,44 @@
package common
import (
"crypto/sha256"
"encoding/hex"
"os"
"time"
)
type AgentFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
ModifiedTime time.Time `json:"modified_time"`
Version string `json:"version,omitempty"`
Checksum string `json:"checksum"`
Required bool `json:"required"`
Migrate bool `json:"migrate"`
Description string `json:"description"`
}
// CalculateChecksum computes SHA256 checksum of a file
func CalculateChecksum(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:]), nil
}
// IsRequiredFile determines if a file is required for agent operation
func IsRequiredFile(path string) bool {
requiredFiles := []string{
"/etc/redflag/agent/config.json", // Agent config in nested structure
"/usr/local/bin/redflag-agent",
"/etc/systemd/system/redflag-agent.service",
}
for _, rf := range requiredFiles {
if path == rf {
return true
}
}
return false
}

View file

@ -0,0 +1,941 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/version"
"github.com/gofrs/uuid/v5"
)
var teeLogger *event.TeeLogger
// InitLogger sets the package-level TeeLogger for dual-output logging.
func InitLogger(l *event.TeeLogger) {
teeLogger = l
}
// MigrationState tracks migration completion status (used by migration package)
type MigrationState struct {
LastCompleted map[string]time.Time `json:"last_completed"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version"`
Timestamp time.Time `json:"timestamp"`
Success bool `json:"success"`
RollbackPath string `json:"rollback_path,omitempty"`
CompletedMigrations []string `json:"completed_migrations"`
}
// ProxyConfig holds proxy configuration
type ProxyConfig struct {
Enabled bool `json:"enabled"`
HTTP string `json:"http,omitempty"` // HTTP proxy URL
HTTPS string `json:"https,omitempty"` // HTTPS proxy URL
NoProxy string `json:"no_proxy,omitempty"` // Comma-separated hosts to bypass proxy
Username string `json:"username,omitempty"` // Proxy username (optional)
Password string `json:"password,omitempty"` // Proxy password (optional)
}
// TLSConfig holds TLS/security configuration
type TLSConfig struct {
InsecureSkipVerify bool `json:"insecure_skip_verify"` // Skip TLS certificate verification
CertFile string `json:"cert_file,omitempty"` // Client certificate file
KeyFile string `json:"key_file,omitempty"` // Client key file
CAFile string `json:"ca_file,omitempty"` // CA certificate file
}
// NetworkConfig holds network-related configuration
type NetworkConfig struct {
Timeout time.Duration `json:"timeout"` // Request timeout
RetryCount int `json:"retry_count"` // Number of retries
RetryDelay time.Duration `json:"retry_delay"` // Delay between retries
MaxIdleConn int `json:"max_idle_conn"` // Maximum idle connections
}
// LoggingConfig holds logging configuration
type LoggingConfig struct {
Level string `json:"level"` // Log level (debug, info, warn, error)
File string `json:"file,omitempty"` // Log file path (optional)
MaxSize int `json:"max_size"` // Max log file size in MB
MaxBackups int `json:"max_backups"` // Max number of log file backups
MaxAge int `json:"max_age"` // Max age of log files in days
}
// SecurityLogConfig holds configuration for security logging
type SecurityLogConfig struct {
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_SECURITY_LOG_ENABLED" default:"true"`
Level string `json:"level" env:"REDFLAG_AGENT_SECURITY_LOG_LEVEL" default:"warning"` // none, error, warn, info, debug
LogSuccesses bool `json:"log_successes" env:"REDFLAG_AGENT_SECURITY_LOG_SUCCESSES" default:"false"`
FilePath string `json:"file_path" env:"REDFLAG_AGENT_SECURITY_LOG_PATH"` // Relative to agent data directory
MaxSizeMB int `json:"max_size_mb" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_SIZE" default:"50"`
MaxFiles int `json:"max_files" env:"REDFLAG_AGENT_SECURITY_LOG_MAX_FILES" default:"5"`
BatchSize int `json:"batch_size" env:"REDFLAG_AGENT_SECURITY_LOG_BATCH_SIZE" default:"10"`
SendToServer bool `json:"send_to_server" env:"REDFLAG_AGENT_SECURITY_LOG_SEND" default:"true"`
}
// CommandSigningConfig holds configuration for command signature verification
type CommandSigningConfig struct {
Enabled bool `json:"enabled" env:"REDFLAG_AGENT_COMMAND_SIGNING_ENABLED" default:"true"`
EnforcementMode string `json:"enforcement_mode" env:"REDFLAG_AGENT_COMMAND_ENFORCEMENT_MODE" default:"strict"` // strict, warning, disabled
// StaleKeyMaxAgeHours bounds how long the agent serves its cached server
// public key while the server is unreachable (SEC-028). 0 = built-in default.
// The agent clamps to a doctrinal ceiling regardless; this only tunes within
// it. Delivered fleet-wide via GET /api/v1/agents/:id/config.
StaleKeyMaxAgeHours int `json:"stale_key_max_age_hours,omitempty" env:"REDFLAG_AGENT_STALE_KEY_MAX_AGE_HOURS"`
}
// PollingConfig holds admin-adjustable polling resilience tuning. These shape
// the check-in jitter and the reconnect backoff curve. Zero values fall back to
// built-in defaults (see the resilience defaults in internal/agent/loop.go), so
// older config files without these fields keep working.
type PollingConfig struct {
JitterMaxSeconds int `json:"jitter_max_seconds,omitempty"` // cap on proportional check-in jitter (default 30)
BackoffBaseSeconds int `json:"backoff_base_seconds,omitempty"` // reconnect backoff floor (default 10)
BackoffMaxSeconds int `json:"backoff_max_seconds,omitempty"` // reconnect backoff ceiling (default 300)
}
// ProcessExplorerConfig holds limits for process detail data collection.
// Zero values use the built-in defaults.
type ProcessExplorerConfig struct {
MaxOpenFiles int `json:"max_open_files,omitempty"` // Cap on open files per process (default 2000)
MaxSockets int `json:"max_sockets,omitempty"` // Cap on open sockets per process (default 500)
MaxPipes int `json:"max_pipes,omitempty"` // Cap on open pipes per process (default 500)
MaxMemoryMap int `json:"max_memory_map,omitempty"` // Cap on memory map entries per process (default 2000)
MaxNamespaces int `json:"max_namespaces,omitempty"` // Cap on namespace entries per process (default 50)
MaxEnvKeys int `json:"max_env_keys,omitempty"` // Cap on environment variable keys per process (default 200)
MaxListeningPorts int `json:"max_listening_ports,omitempty"` // Cap on listening ports per process (default 100)
}
// Config holds agent configuration
type Config struct {
// Version Information
Version string `json:"version,omitempty"` // Config schema version
AgentVersion string `json:"agent_version,omitempty"` // Agent binary version
// Server Configuration
ServerURL string `json:"server_url"`
RegistrationToken string `json:"registration_token,omitempty"` // One-time registration token
// Agent Authentication
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"` // Short-lived access token (24h)
RefreshToken string `json:"refresh_token"` // Long-lived refresh token (90d)
// Agent Behavior
CheckInInterval int `json:"check_in_interval"`
// Rapid polling mode for faster response during operations
RapidPollingEnabled bool `json:"rapid_polling_enabled"`
RapidPollingUntil time.Time `json:"rapid_polling_until"`
// Polling resilience tuning (admin-adjustable; server authority)
Polling PollingConfig `json:"polling,omitempty"`
// Degraded mode for operation after repeated failures
DegradedMode bool `json:"degraded_mode"`
// Network Configuration
Network NetworkConfig `json:"network,omitempty"`
// Proxy Configuration
Proxy ProxyConfig `json:"proxy,omitempty"`
// Security Configuration
TLS TLSConfig `json:"tls,omitempty"`
// Logging Configuration
Logging LoggingConfig `json:"logging,omitempty"`
// Security Logging Configuration
SecurityLogging SecurityLogConfig `json:"security_logging,omitempty"`
// Command Signing Configuration
CommandSigning CommandSigningConfig `json:"command_signing,omitempty"`
// Package Hash Verification Configuration
PackageHashes map[string]string `json:"package_hashes,omitempty"` // package_name:expected_sha256
// Agent Metadata
Tags []string `json:"tags,omitempty"` // User-defined tags
Metadata map[string]string `json:"metadata,omitempty"` // Custom metadata
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"`
// Desktop App Configuration
Desktop DesktopConfig `json:"desktop,omitempty"`
// Process Explorer Configuration
ProcessExplorer ProcessExplorerConfig `json:"process_explorer,omitempty"`
// Migration State
MigrationState *MigrationState `json:"migration_state,omitempty"` // Migration completion tracking
}
// DesktopConfig controls the native Qt/QML local-machine operations console.
// The agent service spawns the desktop binary as a child process when a
// desktop session is detected. The binary connects back to the agent's
// local API socket.
type DesktopConfig struct {
Enabled bool `json:"enabled"` // Whether to auto-launch the desktop app
MaxRestarts int `json:"max_restarts"` // Max restarts before giving up (0 = unlimited)
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
}
// Load reads configuration from multiple sources with priority order:
// 1. CLI flags
// 2. Environment variables
// 3. Configuration file
// 4. Default values
func Load(configPath string, cliFlags *CLIFlags) (*Config, error) {
// Load existing config from file first
config, err := loadFromFile(configPath)
if err != nil {
// Only use defaults if file doesn't exist or can't be read
config = getDefaultConfig()
} else {
// Config loaded and merged with defaults. Persist any new keys that
// the upgrade brought in but were not in the on-disk file — the loaded
// struct has them via mergeConfigPreservingDefaults, but the file
// does not. This catches new fields (desktop, polling, etc.) that
// fresh installs get from the template but upgrades miss.
persistNewDefaults(configPath, config)
}
// Override with environment variables
mergeConfig(config, loadFromEnv())
// Override with CLI flags (highest priority)
if cliFlags != nil {
mergeConfig(config, loadFromFlags(cliFlags))
}
// Validate configuration
if err := validateConfig(config); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return config, nil
}
// CLIFlags holds command line flag values
type CLIFlags struct {
ServerURL string
RegistrationToken string
ProxyHTTP string
ProxyHTTPS string
ProxyNoProxy string
LogLevel string
ConfigFile string
Tags []string
Organization string
DisplayName string
InsecureTLS bool
}
// getConfigVersionForAgent extracts the config version from the agent version
// Agent version format: v0.1.23.6 where the fourth octet (.6) maps to config version
func getConfigVersionForAgent(agentVersion string) string {
// Strip 'v' prefix if present
cleanVersion := strings.TrimPrefix(agentVersion, "v")
// Split version parts
parts := strings.Split(cleanVersion, ".")
if len(parts) == 4 {
// Return the fourth octet as the config version
// v0.1.23.6 → "6"
return parts[3]
}
// TODO: Integrate with global error logging system when available
// For now, default to "6" to match current agent version
return "6"
}
// getDefaultConfig returns default configuration values
func getDefaultConfig() *Config {
// Use version package for single source of truth
configVersion := version.ConfigVersion
if configVersion == "dev" {
// Fallback to extracting from agent version if not injected
configVersion = version.ExtractConfigVersionFromAgent(version.Version)
}
return &Config{
Version: configVersion, // Config schema version from version package
AgentVersion: version.Version, // Agent version from version package
ServerURL: "http://localhost:8080",
CheckInInterval: 300, // 5 minutes
// Server Authentication
RegistrationToken: "", // One-time registration token (embedded by install script)
AgentID: uuid.Nil, // Will be set during registration
Token: "", // Will be set during registration
RefreshToken: "", // Will be set during registration
// Agent Behavior
RapidPollingEnabled: false,
RapidPollingUntil: time.Time{},
DegradedMode: false,
// Polling resilience tuning (defaults; operator/server may override)
Polling: PollingConfig{
JitterMaxSeconds: 30,
BackoffBaseSeconds: 10,
BackoffMaxSeconds: 300,
},
// Network Security
Proxy: ProxyConfig{},
TLS: TLSConfig{},
Network: NetworkConfig{
Timeout: 30 * time.Second,
RetryCount: 3,
RetryDelay: 5 * time.Second,
MaxIdleConn: 10,
},
Logging: LoggingConfig{
Level: "info",
MaxSize: 100, // 100MB
MaxBackups: 3,
MaxAge: 28, // 28 days
},
SecurityLogging: SecurityLogConfig{
Enabled: true,
Level: "warning",
LogSuccesses: false,
FilePath: "security.log",
MaxSizeMB: 50,
MaxFiles: 5,
BatchSize: 10,
SendToServer: true,
},
CommandSigning: CommandSigningConfig{
Enabled: true,
EnforcementMode: "strict",
},
Subsystems: GetDefaultSubsystemsConfig(),
ProcessExplorer: ProcessExplorerConfig{
MaxOpenFiles: 2000,
MaxSockets: 500,
MaxPipes: 500,
MaxMemoryMap: 2000,
MaxNamespaces: 50,
MaxEnvKeys: 200,
MaxListeningPorts: 100,
},
Desktop: DesktopConfig{
Enabled: true,
MaxRestarts: 3,
RestartDelaySec: 5,
},
Tags: []string{},
Metadata: make(map[string]string),
}
}
// loadFromFile reads configuration from file with backward compatibility migration
func loadFromFile(configPath string) (*Config, error) {
// Ensure directory exists
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create config directory: %w", err)
}
// Read config file
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("config file does not exist") // Return error so caller uses defaults
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
// Parse the existing config into a generic map to preserve all fields
var rawConfig map[string]interface{}
if err := json.Unmarshal(data, &rawConfig); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Create a new config with ALL defaults to fill missing fields
config := getDefaultConfig()
// Carefully merge the loaded config into our defaults
// This preserves existing values while filling missing ones with defaults
configJSON, err := json.Marshal(rawConfig)
if err != nil {
return nil, fmt.Errorf("failed to re-marshal config: %w", err)
}
// Create a temporary config to hold loaded values
tempConfig := &Config{}
if err := json.Unmarshal(configJSON, &tempConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal temp config: %w", err)
}
// Merge loaded config into defaults (only non-zero values)
mergeConfigPreservingDefaults(config, tempConfig)
// Handle specific migrations for known breaking changes
migrateConfig(config)
return config, nil
}
// migrateConfig handles specific known migrations between config versions
func migrateConfig(cfg *Config) {
// Save the registration token before migration
savedRegistrationToken := cfg.RegistrationToken
// Update config schema version to latest
targetVersion := version.ConfigVersion
if targetVersion == "dev" {
// Fallback to extracting from agent version
targetVersion = version.ExtractConfigVersionFromAgent(version.Version)
}
if cfg.Version != targetVersion {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "migrating config schema", map[string]interface{}{
"from_version": cfg.Version,
"to_version": targetVersion,
})
}
cfg.Version = targetVersion
}
// Migration 1: Ensure minimum check-in interval (30 seconds)
if cfg.CheckInInterval < 30 {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "migrating check_in_interval to minimum 30 seconds", map[string]interface{}{
"old_value": cfg.CheckInInterval,
})
}
cfg.CheckInInterval = 300 // Default to 5 minutes for better performance
}
// Migration 2: Add missing subsystem fields with defaults
// Check if subsystem is zero value (truly missing), not just has zero fields
if cfg.Subsystems.System == (SubsystemConfig{}) {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "adding missing subsystem", map[string]interface{}{
"subsystem": "system",
})
}
cfg.Subsystems.System = GetDefaultSubsystemsConfig().System
}
if cfg.Subsystems.Updates == (SubsystemConfig{}) {
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "adding missing subsystem", map[string]interface{}{
"subsystem": "updates",
})
}
cfg.Subsystems.Updates = GetDefaultSubsystemsConfig().Updates
}
// CRITICAL: Restore the registration token after migration
// This ensures the token is never overwritten by migration logic
if savedRegistrationToken != "" {
cfg.RegistrationToken = savedRegistrationToken
}
}
// loadFromEnv loads configuration from environment variables
func loadFromEnv() *Config {
config := &Config{}
if serverURL := os.Getenv("REDFLAG_SERVER_URL"); serverURL != "" {
config.ServerURL = serverURL
}
if token := os.Getenv("REDFLAG_REGISTRATION_TOKEN"); token != "" {
config.RegistrationToken = token
}
if proxyHTTP := os.Getenv("REDFLAG_HTTP_PROXY"); proxyHTTP != "" {
config.Proxy.Enabled = true
config.Proxy.HTTP = proxyHTTP
}
if proxyHTTPS := os.Getenv("REDFLAG_HTTPS_PROXY"); proxyHTTPS != "" {
config.Proxy.Enabled = true
config.Proxy.HTTPS = proxyHTTPS
}
if noProxy := os.Getenv("REDFLAG_NO_PROXY"); noProxy != "" {
config.Proxy.NoProxy = noProxy
}
if logLevel := os.Getenv("REDFLAG_LOG_LEVEL"); logLevel != "" {
if config.Logging == (LoggingConfig{}) {
config.Logging = LoggingConfig{}
}
config.Logging.Level = logLevel
}
if org := os.Getenv("REDFLAG_ORGANIZATION"); org != "" {
config.Organization = org
}
if displayName := os.Getenv("REDFLAG_DISPLAY_NAME"); displayName != "" {
config.DisplayName = displayName
}
// Security logging environment variables
if secEnabled := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_ENABLED"); secEnabled != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.Enabled = secEnabled == "true"
}
if secLevel := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_LEVEL"); secLevel != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.Level = secLevel
}
if secLogSucc := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_SUCCESSES"); secLogSucc != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.LogSuccesses = secLogSucc == "true"
}
if secPath := os.Getenv("REDFLAG_AGENT_SECURITY_LOG_PATH"); secPath != "" {
if config.SecurityLogging == (SecurityLogConfig{}) {
config.SecurityLogging = SecurityLogConfig{}
}
config.SecurityLogging.FilePath = secPath
}
return config
}
// loadFromFlags loads configuration from CLI flags
func loadFromFlags(flags *CLIFlags) *Config {
config := &Config{}
if flags.ServerURL != "" {
config.ServerURL = flags.ServerURL
}
if flags.RegistrationToken != "" {
config.RegistrationToken = flags.RegistrationToken
}
if flags.ProxyHTTP != "" || flags.ProxyHTTPS != "" {
config.Proxy = ProxyConfig{
Enabled: true,
HTTP: flags.ProxyHTTP,
HTTPS: flags.ProxyHTTPS,
NoProxy: flags.ProxyNoProxy,
}
}
if flags.LogLevel != "" {
config.Logging = LoggingConfig{
Level: flags.LogLevel,
}
}
if len(flags.Tags) > 0 {
config.Tags = flags.Tags
}
if flags.Organization != "" {
config.Organization = flags.Organization
}
if flags.DisplayName != "" {
config.DisplayName = flags.DisplayName
}
if flags.InsecureTLS {
config.TLS = TLSConfig{
InsecureSkipVerify: true,
}
}
return config
}
// mergeConfig merges source config into target config (non-zero values only)
func mergeConfig(target, source *Config) {
if source.ServerURL != "" {
target.ServerURL = source.ServerURL
}
if source.RegistrationToken != "" {
target.RegistrationToken = source.RegistrationToken
}
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.Polling.JitterMaxSeconds != 0 {
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
}
if source.Polling.BackoffBaseSeconds != 0 {
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
}
if source.Polling.BackoffMaxSeconds != 0 {
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) {
target.Logging = source.Logging
}
if source.SecurityLogging != (SecurityLogConfig{}) {
target.SecurityLogging = source.SecurityLogging
}
if source.CommandSigning != (CommandSigningConfig{}) {
target.CommandSigning = source.CommandSigning
}
// Merge metadata
if source.Tags != nil {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
// Merge subsystems config
if source.Subsystems != (SubsystemsConfig{}) {
target.Subsystems = source.Subsystems
}
}
// validateConfig validates configuration values
func validateConfig(config *Config) error {
if config.ServerURL == "" {
return fmt.Errorf("server_url is required")
}
if config.CheckInInterval < 30 {
return fmt.Errorf("check_in_interval must be at least 30 seconds")
}
if config.CheckInInterval > 3600 {
return fmt.Errorf("check_in_interval cannot exceed 3600 seconds (1 hour)")
}
if config.Network.Timeout <= 0 {
return fmt.Errorf("network timeout must be positive")
}
if config.Network.RetryCount < 0 || config.Network.RetryCount > 10 {
return fmt.Errorf("retry_count must be between 0 and 10")
}
// Validate log level
validLogLevels := map[string]bool{
"debug": true, "info": true, "warn": true, "error": true,
}
if config.Logging.Level != "" && !validLogLevels[config.Logging.Level] {
return fmt.Errorf("invalid log level: %s", config.Logging.Level)
}
return nil
}
// mergeMissingKeys recursively walks fresh and injects keys that are absent
// from raw into raw. Both maps are map[string]interface{} as parsed from JSON.
// Returns the total number of keys added (at any depth).
func mergeMissingKeys(raw, fresh map[string]interface{}, depth int) int {
if depth > 16 {
return 0 // safety limit — deeply nested config is pathological
}
added := 0
for k, v := range fresh {
rawVal, exists := raw[k]
if !exists {
raw[k] = v
added++
continue
}
// If both sides are maps, recurse to find missing sub-keys.
rawMap, rawOk := rawVal.(map[string]interface{})
freshMap, freshOk := v.(map[string]interface{})
if rawOk && freshOk {
added += mergeMissingKeys(rawMap, freshMap, depth+1)
}
}
return added
}
// persistNewDefaults injects new keys from the merged config into the on-disk
// file without removing unknown keys (e.g. machine_id) that the Config struct
// does not carry. This is how upgrades get new config fields (desktop, polling,
// etc.) persisted without the installer re-running.
func persistNewDefaults(configPath string, cfg *Config) {
existing, err := os.ReadFile(configPath)
if err != nil {
return
}
var raw map[string]interface{}
if err := json.Unmarshal(existing, &raw); err != nil {
return
}
// Marshal the merged struct to a map to discover new-key candidates.
cfgJSON, err := json.Marshal(cfg)
if err != nil {
return
}
var fresh map[string]interface{}
if err := json.Unmarshal(cfgJSON, &fresh); err != nil {
return
}
// Inject keys present in the struct map but absent from the raw file.
// Recurse into nested objects so new sub-fields (e.g. desktop.max_restarts
// added in a newer version) are written even when the parent key exists.
added := mergeMissingKeys(raw, fresh, 0)
if added == 0 {
return // already up to date
}
if teeLogger != nil {
teeLogger.Info("agent", "config", "config", "persisting new default keys to config.json", map[string]interface{}{
"added": added,
})
}
merged, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return
}
if err := os.WriteFile(configPath, merged, 0600); err != nil {
if teeLogger != nil {
teeLogger.Warning("agent", "config", "config", "failed to persist new defaults", map[string]interface{}{
"error": err.Error(),
})
}
}
}
// Save writes configuration to file
func (c *Config) Save(configPath string) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create parent directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// SetDegradedMode sets the degraded mode flag and saves the config
func (c *Config) SetDegradedMode(enabled bool) error {
c.DegradedMode = enabled
return c.Save(constants.GetAgentConfigPath())
}
// IsRegistered checks if the agent is registered
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}
// IsStandalone reports whether this config has a stable local identity and no
// fleet credential. A partial fleet enrollment is not standalone: refresh or
// registration material must never silently become local mutation authority.
func (c *Config) IsStandalone() bool {
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
}
// InitializeStandalone gives a local-only Agent one durable UUID. It is
// idempotent, but refuses any fleet credential so provisioning cannot convert a
// fleet host into local authority by accident.
func (c *Config) InitializeStandalone() error {
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
}
if c.AgentID != uuid.Nil {
return nil
}
id, err := uuid.NewV4()
if err != nil {
return fmt.Errorf("generate standalone agent id: %w", err)
}
c.AgentID = id
return nil
}
// 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
}
// HasRegistrationToken checks if the agent has a registration token
func (c *Config) HasRegistrationToken() bool {
return c.RegistrationToken != ""
}
// mergeConfigPreservingDefaults merges source config into target config
// but only overwrites fields that are explicitly set (non-zero)
// This is different from mergeConfig which blindly copies non-zero values
func mergeConfigPreservingDefaults(target, source *Config) {
// Server Configuration
if source.ServerURL != "" && source.ServerURL != getDefaultConfig().ServerURL {
target.ServerURL = source.ServerURL
}
// IMPORTANT: Never overwrite registration token if target already has one
if source.RegistrationToken != "" && target.RegistrationToken == "" {
target.RegistrationToken = source.RegistrationToken
}
// Agent Configuration
if source.CheckInInterval != 0 {
target.CheckInInterval = source.CheckInInterval
}
if source.Polling.JitterMaxSeconds != 0 {
target.Polling.JitterMaxSeconds = source.Polling.JitterMaxSeconds
}
if source.Polling.BackoffBaseSeconds != 0 {
target.Polling.BackoffBaseSeconds = source.Polling.BackoffBaseSeconds
}
if source.Polling.BackoffMaxSeconds != 0 {
target.Polling.BackoffMaxSeconds = source.Polling.BackoffMaxSeconds
}
if source.AgentID != uuid.Nil {
target.AgentID = source.AgentID
}
if source.Token != "" {
target.Token = source.Token
}
if source.RefreshToken != "" {
target.RefreshToken = source.RefreshToken
}
// Merge nested configs only if they're not default values
if source.Network != (NetworkConfig{}) {
target.Network = source.Network
}
if source.Proxy != (ProxyConfig{}) {
target.Proxy = source.Proxy
}
if source.TLS != (TLSConfig{}) {
target.TLS = source.TLS
}
if source.Logging != (LoggingConfig{}) && source.Logging.Level != "" {
target.Logging = source.Logging
}
if source.SecurityLogging != (SecurityLogConfig{}) {
target.SecurityLogging = source.SecurityLogging
}
if source.CommandSigning != (CommandSigningConfig{}) {
target.CommandSigning = source.CommandSigning
}
// Merge metadata
if source.Tags != nil && len(source.Tags) > 0 {
target.Tags = source.Tags
}
if source.Metadata != nil {
if target.Metadata == nil {
target.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
target.Metadata[k] = v
}
}
if source.DisplayName != "" {
target.DisplayName = source.DisplayName
}
if source.Organization != "" {
target.Organization = source.Organization
}
// Merge rapid polling settings
target.RapidPollingEnabled = source.RapidPollingEnabled
if !source.RapidPollingUntil.IsZero() {
target.RapidPollingUntil = source.RapidPollingUntil
}
// Merge subsystems config
if source.Subsystems != (SubsystemsConfig{}) {
target.Subsystems = source.Subsystems
}
// Desktop app config. A zero struct means the key is absent from the file —
// keep the defaults. Any explicit setting (enabled, restart tuning) wins.
if source.Desktop != (DesktopConfig{}) {
target.Desktop = source.Desktop
}
// Version info
if source.Version != "" {
target.Version = source.Version
}
if source.AgentVersion != "" {
target.AgentVersion = source.AgentVersion
}
}

View file

@ -0,0 +1,210 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// TestConfigRoundtripPreservesUnknownKeys is the canonical roundtrip test.
// The config file is the source of truth — keys unknown to the Go struct
// (machine_id, operator-added fields, future features) MUST survive a
// full load+save cycle intact. Regression test for the upgrade-path gap
// where marshal-unmarshal through the struct would silently drop them.
func TestConfigRoundtripPreservesUnknownKeys(t *testing.T) {
// Craft a config file with keys the struct does not know about.
// machine_id is the load-bearing example; "operator_note" simulates
// any future field an admin or config-manager adds outside the agent.
raw := `{
"server_url": "http://test:8080",
"agent_id": "00000000-0000-0000-0000-000000000001",
"check_in_interval": 300,
"machine_id": "host-abc-123-xyz",
"operator_note": "do not remove",
"desktop": {"enabled": true, "max_restarts": 3}
}`
td := t.TempDir()
configPath := filepath.Join(td, "config.json")
if err := os.WriteFile(configPath, []byte(raw), 0600); err != nil {
t.Fatal(err)
}
// Load — this triggers loadFromFile + mergeConfigPreservingDefaults
// + persistNewDefaults writes back.
cfg, err := Load(configPath, nil)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Verify the struct got the known keys.
if cfg.ServerURL != "http://test:8080" {
t.Errorf("ServerURL = %q, want %q", cfg.ServerURL, "http://test:8080")
}
if cfg.CheckInInterval != 300 {
t.Errorf("CheckInInterval = %d, want 300", cfg.CheckInInterval)
}
if !cfg.Desktop.Enabled {
t.Error("Desktop.Enabled = false, want true (was missing from old config, should be injected by persistNewDefaults)")
}
// Read the written file back raw. Parse as map[string]interface{} to
// see keys the struct does not know about.
written, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var rawWritten map[string]interface{}
if err := json.Unmarshal(written, &rawWritten); err != nil {
t.Fatalf("written config is not valid JSON: %v\n%s", err, string(written))
}
// DID NOT LOSE machine_id — the load-bearing unknown key.
if _, exists := rawWritten["machine_id"]; !exists {
t.Errorf("FATAL: machine_id lost in config roundtrip — unknown keys are silently dropped")
}
// DID NOT LOSE operator_note — arbitrary operator-added key.
if _, exists := rawWritten["operator_note"]; !exists {
t.Errorf("operator_note lost in config roundtrip — operator-added keys are silently dropped")
}
// DID ADD desktop — new struct key not in the original file.
if _, exists := rawWritten["desktop"]; !exists {
t.Errorf("desktop missing from written config — persistNewDefaults did not inject it")
}
// Verify machine_id retained its value exactly.
machineID, _ := rawWritten["machine_id"].(string)
if machineID != "host-abc-123-xyz" {
t.Errorf("machine_id = %q, want %q", machineID, "host-abc-123-xyz")
}
// Verify operator_note retained its value exactly.
note, _ := rawWritten["operator_note"].(string)
if note != "do not remove" {
t.Errorf("operator_note = %q, want %q", note, "do not remove")
}
// Verify desktop sub-fields were preserved (max_restarts from the file).
desktop, ok := rawWritten["desktop"].(map[string]interface{})
if !ok {
t.Error("desktop is not a map in written config")
} else {
if mr, ok := desktop["max_restarts"].(float64); !ok || mr != 3 {
t.Errorf("desktop.max_restarts = %v, want 3 (preserved from file)", desktop["max_restarts"])
}
}
t.Logf("roundtrip OK — %d keys in written config", len(rawWritten))
}
// TestPersistNewDefaultsInjectsNestedSubFields verifies that new sub-fields
// introduced in a version upgrade are written even when the parent key already
// exists on disk. E.g. an old config has desktop:{"enabled":true} and the new
// version adds max_restarts / restart_delay_sec — those must land on disk.
func TestPersistNewDefaultsInjectsNestedSubFields(t *testing.T) {
// Old config: desktop with only "enabled" — missing max_restarts and
// restart_delay_sec that a newer agent version carries as defaults.
raw := `{
"server_url": "http://test:8080",
"agent_id": "00000000-0000-0000-0000-000000000003",
"check_in_interval": 300,
"desktop": {"enabled": true}
}`
td := t.TempDir()
configPath := filepath.Join(td, "config.json")
if err := os.WriteFile(configPath, []byte(raw), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(configPath, nil)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// The merged struct must have the new sub-fields present (defaults are 0
// for both: 0 = unlimited restarts, 0 = default restart delay).
if cfg.Desktop.MaxRestarts != 0 {
t.Logf("Desktop.MaxRestarts = %d (non-zero default — ok)", cfg.Desktop.MaxRestarts)
}
if cfg.Desktop.RestartDelaySec != 0 {
t.Logf("Desktop.RestartDelaySec = %d (non-zero default — ok)", cfg.Desktop.RestartDelaySec)
}
// Read the written file — the new sub-fields must be on disk.
written, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var rawWritten map[string]interface{}
if err := json.Unmarshal(written, &rawWritten); err != nil {
t.Fatalf("written config is not valid JSON: %v", err)
}
desktop, ok := rawWritten["desktop"].(map[string]interface{})
if !ok {
t.Fatal("desktop missing from written config entirely")
}
if e, ok := desktop["enabled"].(bool); !ok || !e {
t.Errorf("desktop.enabled = %v, want true (original value lost)", desktop["enabled"])
}
if _, exists := desktop["max_restarts"]; !exists {
t.Error("desktop.max_restarts missing — persistNewDefaults did not inject new nested sub-field")
}
if _, exists := desktop["restart_delay_sec"]; !exists {
t.Error("desktop.restart_delay_sec missing — persistNewDefaults did not inject new nested sub-field")
}
t.Logf("nested sub-field injection OK — %d top-level keys, desktop has %d sub-keys",
len(rawWritten), len(desktop))
}
// TestConfigLoadDoesNotRemoveKeys checks that loading a config with extra
// keys (from a future agent version) does not strip them in memory.
// The file is the authority; the load path must not filter by struct tags.
func TestConfigLoadDoesNotRemoveKeys(t *testing.T) {
raw := `{
"server_url": "http://test:8080",
"agent_id": "00000000-0000-0000-0000-000000000002",
"check_in_interval": 300,
"machine_id": "should-survive",
"future_feature": {"enabled": true}
}`
td := t.TempDir()
configPath := filepath.Join(td, "config.json")
if err := os.WriteFile(configPath, []byte(raw), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(configPath, nil)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Known fields populated.
if cfg.ServerURL != "http://test:8080" {
t.Errorf("ServerURL = %q", cfg.ServerURL)
}
if cfg.CheckInInterval != 300 {
t.Errorf("CheckInInterval = %d", cfg.CheckInInterval)
}
// The file on disk was NOT changed if it already had the struct keys.
written, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
var rawWritten map[string]interface{}
if err := json.Unmarshal(written, &rawWritten); err != nil {
t.Fatalf("written config invalid: %v", err)
}
if _, exists := rawWritten["machine_id"]; !exists {
t.Error("machine_id removed by load")
}
if _, exists := rawWritten["future_feature"]; !exists {
t.Error("future_feature removed by load")
}
}

View file

@ -0,0 +1,195 @@
package config
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strings"
)
// DockerSecretsConfig holds Docker secrets configuration
type DockerSecretsConfig struct {
Enabled bool `json:"enabled"`
SecretsPath string `json:"secrets_path"`
EncryptionKey string `json:"encryption_key,omitempty"`
Secrets map[string]string `json:"secrets,omitempty"`
}
// LoadDockerConfig loads Docker configuration if available
func LoadDockerConfig(configPath string) (*DockerSecretsConfig, error) {
dockerConfigPath := filepath.Join(configPath, "docker.json")
// Check if Docker config exists
if _, err := os.Stat(dockerConfigPath); os.IsNotExist(err) {
return &DockerSecretsConfig{Enabled: false}, nil
}
data, err := ioutil.ReadFile(dockerConfigPath)
if err != nil {
return nil, fmt.Errorf("failed to read Docker config: %w", err)
}
var dockerConfig DockerSecretsConfig
if err := json.Unmarshal(data, &dockerConfig); err != nil {
return nil, fmt.Errorf("failed to parse Docker config: %w", err)
}
// Set default secrets path if not specified
if dockerConfig.SecretsPath == "" {
dockerConfig.SecretsPath = getDefaultSecretsPath()
}
return &dockerConfig, nil
}
// getDefaultSecretsPath returns the default Docker secrets path for the platform
func getDefaultSecretsPath() string {
if runtime.GOOS == "windows" {
return `C:\ProgramData\Docker\secrets`
}
return "/run/secrets"
}
// ReadSecret reads a secret from Docker secrets or falls back to file
func ReadSecret(secretName, fallbackPath string, dockerConfig *DockerSecretsConfig) ([]byte, error) {
// Try Docker secrets first if enabled
if dockerConfig != nil && dockerConfig.Enabled {
secretPath := filepath.Join(dockerConfig.SecretsPath, secretName)
if data, err := ioutil.ReadFile(secretPath); err == nil {
if teeLogger != nil {
teeLogger.Info("agent", "config", "docker_config", "read secret from docker", map[string]interface{}{
"secret_name": secretName,
})
}
return data, nil
}
}
// Fall back to file system
if fallbackPath != "" {
if data, err := ioutil.ReadFile(fallbackPath); err == nil {
if teeLogger != nil {
teeLogger.Info("agent", "config", "docker_config", "read secret from file", map[string]interface{}{
"file_path": fallbackPath,
})
}
return data, nil
}
}
return nil, fmt.Errorf("secret not found: %s", secretName)
}
// MergeConfigWithSecrets merges configuration with Docker secrets
func MergeConfigWithSecrets(config *Config, dockerConfig *DockerSecretsConfig) error {
if dockerConfig == nil || !dockerConfig.Enabled {
return nil
}
// If there's an encrypted config, decrypt and merge it
if encryptedConfigPath, exists := dockerConfig.Secrets["config"]; exists {
if err := mergeEncryptedConfig(config, encryptedConfigPath, dockerConfig.EncryptionKey); err != nil {
return fmt.Errorf("failed to merge encrypted config: %w", err)
}
}
// Apply other secrets to configuration
if err := applySecretsToConfig(config, dockerConfig); err != nil {
return fmt.Errorf("failed to apply secrets to config: %w", err)
}
return nil
}
// mergeEncryptedConfig decrypts and merges encrypted configuration
func mergeEncryptedConfig(config *Config, encryptedPath, encryptionKey string) error {
if encryptionKey == "" {
return fmt.Errorf("no encryption key available for encrypted config")
}
// Create temporary file for decrypted config
tempPath := encryptedPath + ".tmp"
defer os.Remove(tempPath)
// Decrypt the config file
// Note: This would need to import the migration package's DecryptFile function
// For now, we'll assume the decryption happens elsewhere
return fmt.Errorf("encrypted config merge not yet implemented")
}
// applySecretsToConfig applies Docker secrets to configuration fields
func applySecretsToConfig(config *Config, dockerConfig *DockerSecretsConfig) error {
// Apply proxy secrets
if proxyUsername, exists := dockerConfig.Secrets["proxy_username"]; exists {
config.Proxy.Username = proxyUsername
}
if proxyPassword, exists := dockerConfig.Secrets["proxy_password"]; exists {
config.Proxy.Password = proxyPassword
}
// Apply TLS secrets
if certFile, exists := dockerConfig.Secrets["tls_cert"]; exists {
config.TLS.CertFile = certFile
}
if keyFile, exists := dockerConfig.Secrets["tls_key"]; exists {
config.TLS.KeyFile = keyFile
}
if caFile, exists := dockerConfig.Secrets["tls_ca"]; exists {
config.TLS.CAFile = caFile
}
// Apply registration token
if regToken, exists := dockerConfig.Secrets["registration_token"]; exists {
config.RegistrationToken = regToken
}
return nil
}
// IsDockerEnvironment checks if the agent is running in Docker
func IsDockerEnvironment() bool {
// Check for .dockerenv file
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
// Check for Docker in cgroup
if data, err := ioutil.ReadFile("/proc/1/cgroup"); err == nil {
if contains(string(data), "docker") {
return true
}
}
return false
}
// SaveDockerConfig saves Docker configuration to disk
func SaveDockerConfig(dockerConfig *DockerSecretsConfig, configPath string) error {
dockerConfigPath := filepath.Join(configPath, "docker.json")
data, err := json.MarshalIndent(dockerConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal Docker config: %w", err)
}
if err := ioutil.WriteFile(dockerConfigPath, data, 0600); err != nil {
return fmt.Errorf("failed to write Docker config: %w", err)
}
if teeLogger != nil {
teeLogger.Info("agent", "config", "docker_config", "saved docker config", map[string]interface{}{
"config_path": dockerConfigPath,
})
}
return nil
}
// contains checks if a string contains a substring (case-insensitive)
func contains(s, substr string) bool {
s = strings.ToLower(s)
substr = strings.ToLower(substr)
return strings.Contains(s, substr)
}

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

@ -0,0 +1,49 @@
package config
import (
"testing"
"github.com/gofrs/uuid/v5"
)
func TestInitializeStandaloneIsStable(t *testing.T) {
cfg := &Config{}
if err := cfg.InitializeStandalone(); err != nil {
t.Fatal(err)
}
first := cfg.AgentID
if first == uuid.Nil || !cfg.IsStandalone() || cfg.IsRegistered() {
t.Fatalf("standalone identity not established: id=%s", first)
}
if err := cfg.InitializeStandalone(); err != nil {
t.Fatal(err)
}
if cfg.AgentID != first {
t.Fatalf("standalone identity changed: %s -> %s", first, cfg.AgentID)
}
}
func TestInitializeStandaloneRefusesFleetMaterial(t *testing.T) {
for name, cfg := range map[string]*Config{
"registration token": {RegistrationToken: "register"},
"access token": {Token: "access"},
"refresh token": {RefreshToken: "refresh"},
} {
t.Run(name, func(t *testing.T) {
if err := cfg.InitializeStandalone(); err == nil {
t.Fatal("fleet material became standalone authority")
}
})
}
}
func TestPartialFleetEnrollmentIsNotStandalone(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
cfg := &Config{AgentID: id, RefreshToken: "refresh"}
if cfg.IsStandalone() {
t.Fatal("partial fleet enrollment reported as standalone")
}
}

View file

@ -0,0 +1,126 @@
package config
import "time"
// SubsystemConfig holds configuration for individual subsystems
type SubsystemConfig struct {
// Execution settings
Enabled bool `json:"enabled"`
Timeout time.Duration `json:"timeout"` // Timeout for this subsystem
// Interval for this subsystem (in minutes)
// This controls how often the server schedules scans for this subsystem
IntervalMinutes int `json:"interval_minutes,omitempty"`
// Circuit breaker settings
CircuitBreaker CircuitBreakerConfig `json:"circuit_breaker"`
}
// CircuitBreakerConfig holds circuit breaker settings for subsystems
type CircuitBreakerConfig struct {
// Enabled controls whether circuit breaker is active
Enabled bool `json:"enabled"`
// FailureThreshold is the number of consecutive failures before opening the circuit
FailureThreshold int `json:"failure_threshold"`
// FailureWindow is the time window to track failures (e.g., 3 failures in 10 minutes)
FailureWindow time.Duration `json:"failure_window"`
// OpenDuration is how long the circuit stays open before attempting recovery
OpenDuration time.Duration `json:"open_duration"`
// HalfOpenAttempts is the number of test attempts in half-open state before fully closing
HalfOpenAttempts int `json:"half_open_attempts"`
}
// SubsystemsConfig holds all subsystem configurations
type SubsystemsConfig struct {
System SubsystemConfig `json:"system"` // System metrics scanner
Updates SubsystemConfig `json:"updates"` // Virtual subsystem for package update scheduling
APT SubsystemConfig `json:"apt"`
DNF SubsystemConfig `json:"dnf"`
Pacman SubsystemConfig `json:"pacman"`
Docker SubsystemConfig `json:"docker"`
Windows SubsystemConfig `json:"windows"`
Winget SubsystemConfig `json:"winget"`
Storage SubsystemConfig `json:"storage"`
}
// GetDefaultSubsystemsConfig returns default subsystem configurations
func GetDefaultSubsystemsConfig() SubsystemsConfig {
// Default circuit breaker config
defaultCB := CircuitBreakerConfig{
Enabled: true,
FailureThreshold: 3, // 3 consecutive failures
FailureWindow: 10 * time.Minute, // within 10 minutes
OpenDuration: 30 * time.Minute, // circuit open for 30 min
HalfOpenAttempts: 2, // 2 successful attempts to close circuit
}
// Aggressive circuit breaker for Windows Update (known to be slow/problematic)
windowsCB := CircuitBreakerConfig{
Enabled: true,
FailureThreshold: 2, // Only 2 failures
FailureWindow: 15 * time.Minute,
OpenDuration: 60 * time.Minute, // Open for 1 hour
HalfOpenAttempts: 3,
}
return SubsystemsConfig{
System: SubsystemConfig{
Enabled: true, // System scanner always available
Timeout: 10 * time.Second, // System info should be fast
IntervalMinutes: 5, // Default: 5 minutes
CircuitBreaker: defaultCB,
},
Updates: SubsystemConfig{
Enabled: true, // Virtual subsystem for package update scheduling
Timeout: 0, // Not used - delegates to individual package scanners
IntervalMinutes: 720, // Default: 12 hours (more reasonable for update checks)
CircuitBreaker: CircuitBreakerConfig{Enabled: false}, // No circuit breaker for virtual subsystem
},
APT: SubsystemConfig{
Enabled: true,
Timeout: 30 * time.Second,
IntervalMinutes: 15, // Default: 15 minutes
CircuitBreaker: defaultCB,
},
DNF: SubsystemConfig{
Enabled: true,
Timeout: 15 * time.Minute, // TODO: Make scanner timeouts user-adjustable via settings. DNF operations can take a long time on large systems
IntervalMinutes: 15, // Default: 15 minutes
CircuitBreaker: defaultCB,
},
Pacman: SubsystemConfig{
Enabled: true,
Timeout: 5 * time.Minute, // checkupdates syncs repo DBs into a temp dir; usually fast but can lag on slow mirrors
IntervalMinutes: 15, // Default: 15 minutes
CircuitBreaker: defaultCB,
},
Docker: SubsystemConfig{
Enabled: true,
Timeout: 60 * time.Second, // Registry queries can be slow
IntervalMinutes: 15, // Default: 15 minutes
CircuitBreaker: defaultCB,
},
Windows: SubsystemConfig{
Enabled: true,
Timeout: 10 * time.Minute, // Windows Update can be VERY slow
IntervalMinutes: 15, // Default: 15 minutes
CircuitBreaker: windowsCB,
},
Winget: SubsystemConfig{
Enabled: true,
Timeout: 2 * time.Minute, // Winget has multiple retry strategies
IntervalMinutes: 15, // Default: 15 minutes
CircuitBreaker: defaultCB,
},
Storage: SubsystemConfig{
Enabled: true,
Timeout: 10 * time.Second, // Disk info should be fast
IntervalMinutes: 5, // Default: 5 minutes
CircuitBreaker: defaultCB,
},
}
}

View file

@ -0,0 +1,169 @@
// Package constants provides centralized path definitions for the RedFlag agent.
// This package ensures consistency across all components and makes path management
// maintainable and testable.
package constants
import (
"path/filepath"
"runtime"
)
// Base directories
const (
LinuxBaseDir = "/var/lib/redflag"
WindowsBaseDir = "C:\\ProgramData\\RedFlag"
)
// Subdirectory structure
const (
AgentDir = "agent"
ServerDir = "server"
CacheSubdir = "cache"
StateSubdir = "state"
MigrationSubdir = "migration_backups"
)
// Config paths
const (
LinuxConfigBase = "/etc/redflag"
WindowsConfigBase = "C:\\ProgramData\\RedFlag"
ConfigFile = "config.json"
)
// Log paths
const (
LinuxLogBase = "/var/log/redflag"
LogSubdir = "logs"
AgentLogFile = "agent.log"
)
// Legacy paths for migration
const (
LegacyConfigPath = "/etc/aggregator/config.json"
LegacyStatePath = "/var/lib/aggregator"
)
// GetBaseDir returns platform-specific base directory
func GetBaseDir() string {
if runtime.GOOS == "windows" {
return WindowsBaseDir
}
return LinuxBaseDir
}
// GetAgentStateDir returns /var/lib/redflag/agent/state
func GetAgentStateDir() string {
return filepath.Join(GetBaseDir(), AgentDir, StateSubdir)
}
// GetAgentCacheDir returns /var/lib/redflag/agent/cache
func GetAgentCacheDir() string {
return filepath.Join(GetBaseDir(), AgentDir, CacheSubdir)
}
// GetMigrationBackupDir returns /var/lib/redflag/agent/migration_backups
func GetMigrationBackupDir() string {
return filepath.Join(GetBaseDir(), AgentDir, MigrationSubdir)
}
// GetAgentConfigPath returns /etc/redflag/agent/config.json
func GetAgentConfigPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(WindowsConfigBase, AgentDir, ConfigFile)
}
return filepath.Join(LinuxConfigBase, AgentDir, ConfigFile)
}
// GetAgentConfigDir returns /etc/redflag/agent
func GetAgentConfigDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(WindowsConfigBase, AgentDir)
}
return filepath.Join(LinuxConfigBase, AgentDir)
}
// GetServerPublicKeyPath returns /etc/redflag/server/server_public_key
func GetServerPublicKeyPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(WindowsConfigBase, ServerDir, "server_public_key")
}
return filepath.Join(LinuxConfigBase, ServerDir, "server_public_key")
}
// GetServerPublicKeyDir returns /etc/redflag/server (directory for server public keys)
func GetServerPublicKeyDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(WindowsConfigBase, ServerDir)
}
return filepath.Join(LinuxConfigBase, ServerDir)
}
// GetAgentLogDir returns the platform-specific agent log directory.
func GetAgentLogDir() string {
if runtime.GOOS == "windows" {
return filepath.Join(WindowsBaseDir, LogSubdir)
}
return filepath.Join(LinuxLogBase, AgentDir)
}
// GetAgentLogPath returns the platform-specific primary agent log file.
func GetAgentLogPath() string {
return filepath.Join(GetAgentLogDir(), AgentLogFile)
}
// GetLegacyAgentConfigPath returns legacy /etc/aggregator/config.json
func GetLegacyAgentConfigPath() string {
return LegacyConfigPath
}
// GetLegacyAgentStatePath returns legacy /var/lib/aggregator
func GetLegacyAgentStatePath() string {
return LegacyStatePath
}
// Staging directory for self-update binaries (pending-upgrade.bin etc.).
// Mirrors /var/lib/redflag/agent on Linux, C:\ProgramData\RedFlag\agent on Windows.
func GetAgentStagingDir() string {
return filepath.Join(GetBaseDir(), AgentDir)
}
// GetAgentStagingPath returns the staging path for a self-update binary.
// suffix is e.g. "pending-upgrade.bin", "pending-helper.bin", "pending-desktop.bin".
func GetAgentStagingPath(suffix string) string {
return filepath.Join(GetBaseDir(), AgentDir, suffix)
}
// Native packages set this at build time; source installs retain their layout.
var LinuxBinaryInstallDir = "/usr/local/bin"
// Binary install paths — source/package layout / C:\Program Files\RedFlag.
func GetBinaryInstallDir() string {
if runtime.GOOS == "windows" {
return `C:\Program Files\RedFlag`
}
return LinuxBinaryInstallDir
}
func GetAgentBinaryPath() string {
name := "redflag-agent"
if runtime.GOOS == "windows" {
name = "redflag-agent.exe"
}
return filepath.Join(GetBinaryInstallDir(), name)
}
func GetHelperBinaryPath() string {
name := "redflag-helper"
if runtime.GOOS == "windows" {
name = "redflag-helper.exe"
}
return filepath.Join(GetBinaryInstallDir(), name)
}
func GetDesktopBinaryPath() string {
name := "redflag-desktop"
if runtime.GOOS == "windows" {
name = "redflag-desktop.exe"
}
return filepath.Join(GetBinaryInstallDir(), name)
}

View file

@ -0,0 +1,382 @@
package crypto
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/event"
)
var teeLogger *event.TeeLogger
// InitLogger sets the package-level TeeLogger for dual-output logging.
func InitLogger(l *event.TeeLogger) {
teeLogger = l
}
const defaultCacheTTLHours = 24
// Stale-cache fallback window (SEC-028). When the server is unreachable the
// agent keeps working on its last-known public key, but only for a bounded
// time: a key the server rotated OUT must not stay trusted forever just because
// the agent can't phone home. The WINDOW LENGTH is operator policy delivered via
// security settings (command_signing.stale_key_max_age_hours). The BOUNDS are
// doctrine and live here, so neither a setting nor a tampered local config can
// widen it past the ceiling or disable it. Forward-only is not a knob; the
// existence of a fail-closed ceiling is fixed, only its length is tunable.
const (
defaultStaleKeyMaxAge = 7 * 24 * time.Hour // policy default; mirrors the server-side setting default
minStaleKeyMaxAge = 1 * time.Hour // doctrinal floor — the window is always bounded
maxStaleKeyMaxAge = 30 * 24 * time.Hour // doctrinal ceiling — forward-only cap, cannot be exceeded
)
// staleKeyMaxAgeNanos holds the active window. Written by SetStaleKeyMaxAge from
// the config-refresh goroutine, read on the command-verify path — atomic so the
// two don't race. Zero means "unset": staleKeyMaxAge() falls back to the default.
var staleKeyMaxAgeNanos atomic.Int64
// staleKeyMaxAge returns the active stale-cache window.
func staleKeyMaxAge() time.Duration {
if n := staleKeyMaxAgeNanos.Load(); n > 0 {
return time.Duration(n)
}
return defaultStaleKeyMaxAge
}
// SetStaleKeyMaxAge applies an operator-configured window (in hours), clamped to
// the doctrinal [min,max] range. hours <= 0 means "unset" and restores the
// default. The clamp is the enforcement point: a setting or local config that
// asks for more than the ceiling gets the ceiling, never the request. Returns
// the window actually applied.
func SetStaleKeyMaxAge(hours int) time.Duration {
d := defaultStaleKeyMaxAge
if hours > 0 {
d = time.Duration(hours) * time.Hour
if d < minStaleKeyMaxAge {
d = minStaleKeyMaxAge
}
if d > maxStaleKeyMaxAge {
d = maxStaleKeyMaxAge
}
}
staleKeyMaxAgeNanos.Store(int64(d))
return d
}
// getPublicKeyDir returns the platform-specific directory for key cache files
// Uses constants package to ensure consistency with other path definitions.
func getPublicKeyDir() string {
return constants.GetServerPublicKeyDir()
}
// getPrimaryKeyPath returns the path for the primary cached public key
// Uses constants package as single source of truth (BUG-012 fix).
func getPrimaryKeyPath() string {
return constants.GetServerPublicKeyPath()
}
// getKeyPathByID returns the path for a specific key cached by key_id
func getKeyPathByID(keyID string) string {
return filepath.Join(getPublicKeyDir(), "server_public_key_"+keyID)
}
// getPrimaryMetaPath returns the metadata file path for the primary key
func getPrimaryMetaPath() string {
return filepath.Join(getPublicKeyDir(), "server_public_key.meta")
}
// CacheMetadata holds metadata about the cached public key
type CacheMetadata struct {
KeyID string `json:"key_id"`
Version int `json:"version"`
CachedAt time.Time `json:"cached_at"`
TTLHours int `json:"ttl_hours"`
}
// IsExpired returns true if the cache TTL has been exceeded
func (m *CacheMetadata) IsExpired() bool {
ttl := time.Duration(m.TTLHours) * time.Hour
if ttl <= 0 {
ttl = defaultCacheTTLHours * time.Hour
}
return time.Since(m.CachedAt) > ttl
}
// PublicKeyResponse represents the server's public key response
type PublicKeyResponse struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
Algorithm string `json:"algorithm"`
KeySize int `json:"key_size"`
KeyID string `json:"key_id"`
Version int `json:"version"`
}
// ActivePublicKeyEntry represents one entry from GET /api/v1/public-keys
type ActivePublicKeyEntry struct {
KeyID string `json:"key_id"`
PublicKey string `json:"public_key"`
IsPrimary bool `json:"is_primary"`
Version int `json:"version"`
Algorithm string `json:"algorithm"`
}
// loadCacheMetadata loads the metadata sidecar file for the primary key
func loadCacheMetadata() (*CacheMetadata, error) {
data, err := os.ReadFile(getPrimaryMetaPath())
if err != nil {
return nil, err
}
var meta CacheMetadata
if err := json.Unmarshal(data, &meta); err != nil {
return nil, err
}
return &meta, nil
}
// saveCacheMetadata writes the metadata sidecar file
func saveCacheMetadata(meta *CacheMetadata) error {
dir := getPublicKeyDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create key dir: %w", err)
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
return os.WriteFile(getPrimaryMetaPath(), data, 0644)
}
// FetchAndCacheServerPublicKey fetches the server's Ed25519 primary public key.
// Uses a TTL+key_id cache: skips the fetch only if both TTL is valid AND key_id matches.
// Implements Trust-On-First-Use (TOFU) with rotation awareness.
func FetchAndCacheServerPublicKey(serverURL string) (ed25519.PublicKey, error) {
// Check if cache is still valid
if meta, err := loadCacheMetadata(); err == nil && meta.KeyID != "" && !meta.IsExpired() {
// Cache metadata is valid and within TTL — try to load the cached key
if cachedKey, err := LoadCachedPublicKey(); err == nil && cachedKey != nil {
return cachedKey, nil
}
// Cache file missing despite valid metadata — fall through to re-fetch
}
// Fetch primary key from server
resp, err := http.Get(serverURL + "/api/v1/public-key")
if err != nil {
// Network failed — serve the cached key only within the bounded staleness
// window (SEC-028). Past the ceiling, or when the cache age can't be
// established, fail closed: forward-only forbids trusting a possibly
// rotated-out key indefinitely.
cachedKey, loadErr := LoadCachedPublicKey()
if loadErr != nil {
return nil, fmt.Errorf("failed to fetch public key from server: %w", err)
}
meta, metaErr := loadCacheMetadata()
if metaErr != nil {
return nil, fmt.Errorf("public key fetch failed and cache age is unknown (no metadata); refusing stale key: %w", err)
}
age := time.Since(meta.CachedAt)
window := staleKeyMaxAge()
if age > window {
return nil, fmt.Errorf("public key fetch failed and cached key is stale (age %s > max %s); refusing: %w",
age.Round(time.Hour), window, err)
}
// Degraded trust, not a routine warning: surface at ERROR so an operator
// sees an agent running on an un-refreshed signing key.
if teeLogger != nil {
teeLogger.Error("agent", "crypto", "pubkey", "server unreachable, serving stale cached public key within bounded window", map[string]interface{}{
"error": err.Error(),
"cache_age": age.Round(time.Minute).String(),
"max_stale": window.String(),
"key_id": meta.KeyID,
})
}
return cachedKey, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(body))
}
var keyResp PublicKeyResponse
if err := json.NewDecoder(resp.Body).Decode(&keyResp); err != nil {
return nil, fmt.Errorf("failed to parse public key response: %w", err)
}
if keyResp.Algorithm != "ed25519" {
return nil, fmt.Errorf("unsupported signature algorithm: %s (expected ed25519)", keyResp.Algorithm)
}
pubKeyBytes, err := hex.DecodeString(keyResp.PublicKey)
if err != nil {
return nil, fmt.Errorf("invalid public key format: %w", err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return nil, fmt.Errorf("invalid public key size: expected %d bytes, got %d", ed25519.PublicKeySize, len(pubKeyBytes))
}
publicKey := ed25519.PublicKey(pubKeyBytes)
// Cache the primary key
if err := cachePublicKey(publicKey); err != nil {
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache primary public key", map[string]interface{}{
"error": err.Error(),
})
}
}
// Use key_id from response (fall back to fingerprint for old servers)
keyID := keyResp.KeyID
if keyID == "" {
keyID = keyResp.Fingerprint
}
// Write metadata sidecar
meta := &CacheMetadata{
KeyID: keyID,
Version: keyResp.Version,
CachedAt: time.Now().UTC(),
TTLHours: defaultCacheTTLHours,
}
if err := saveCacheMetadata(meta); err != nil {
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "pubkey", "failed to save key cache metadata", map[string]interface{}{
"error": err.Error(),
})
}
}
// Also cache by key_id for multi-key lookup
if keyID != "" {
if err := CachePublicKeyByID(keyID, publicKey); err != nil {
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache key by ID", map[string]interface{}{
"key_id": keyID,
"error": err.Error(),
})
}
}
}
if teeLogger != nil {
teeLogger.Info("agent", "crypto", "pubkey", "public key fetched and cached", map[string]interface{}{
"key_id": keyID,
"version": keyResp.Version,
})
}
return publicKey, nil
}
// FetchAndCacheAllActiveKeys fetches all active public keys from GET /api/v1/public-keys
// and caches each one by its key_id. Used during key rotation transition windows.
func FetchAndCacheAllActiveKeys(serverURL string) ([]ActivePublicKeyEntry, error) {
resp, err := http.Get(serverURL + "/api/v1/public-keys")
if err != nil {
return nil, fmt.Errorf("failed to fetch active public keys: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))
}
var entries []ActivePublicKeyEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, fmt.Errorf("failed to decode public keys list: %w", err)
}
for _, entry := range entries {
if entry.Algorithm != "ed25519" {
continue
}
pubKeyBytes, err := hex.DecodeString(entry.PublicKey)
if err != nil || len(pubKeyBytes) != ed25519.PublicKeySize {
continue
}
if err := CachePublicKeyByID(entry.KeyID, ed25519.PublicKey(pubKeyBytes)); err != nil {
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "pubkey", "failed to cache key", map[string]interface{}{
"key_id": entry.KeyID,
"error": err.Error(),
})
}
}
}
return entries, nil
}
// LoadCachedPublicKey loads the primary cached public key from disk (backward compat path)
func LoadCachedPublicKey() (ed25519.PublicKey, error) {
data, err := os.ReadFile(getPrimaryKeyPath())
if err != nil {
return nil, err
}
if len(data) != ed25519.PublicKeySize {
return nil, fmt.Errorf("cached public key has invalid size: %d bytes", len(data))
}
return ed25519.PublicKey(data), nil
}
// LoadCachedPublicKeyByID loads a cached public key by its key_id.
// Falls back to the primary key if the key_id-specific file does not exist.
func LoadCachedPublicKeyByID(keyID string) (ed25519.PublicKey, error) {
if keyID == "" {
return LoadCachedPublicKey()
}
data, err := os.ReadFile(getKeyPathByID(keyID))
if err == nil && len(data) == ed25519.PublicKeySize {
return ed25519.PublicKey(data), nil
}
// Fall back to primary
return LoadCachedPublicKey()
}
// IsKeyIDCached returns true if a key with the given key_id is cached locally
func IsKeyIDCached(keyID string) bool {
if keyID == "" {
return false
}
info, err := os.Stat(getKeyPathByID(keyID))
return err == nil && info.Size() == ed25519.PublicKeySize
}
// cachePublicKey saves the primary public key to disk (backward compat path)
func cachePublicKey(publicKey ed25519.PublicKey) error {
dir := getPublicKeyDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
return os.WriteFile(getPrimaryKeyPath(), publicKey, 0644)
}
// CachePublicKeyByID saves a public key under its key_id filename
func CachePublicKeyByID(keyID string, publicKey ed25519.PublicKey) error {
if keyID == "" {
return fmt.Errorf("keyID cannot be empty")
}
dir := getPublicKeyDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
return os.WriteFile(getKeyPathByID(keyID), publicKey, 0644)
}
// GetPublicKey returns the primary cached public key or fetches it from the server
func GetPublicKey(serverURL string) (ed25519.PublicKey, error) {
// Try with TTL-aware fetch (will use cache if valid)
return FetchAndCacheServerPublicKey(serverURL)
}

View file

@ -0,0 +1,50 @@
package crypto
import (
"testing"
"time"
)
func TestCacheMetadataIsExpired(t *testing.T) {
tests := []struct {
name string
meta CacheMetadata
expected bool
}{
{
name: "fresh_within_ttl",
meta: CacheMetadata{CachedAt: time.Now(), TTLHours: 24},
expected: false,
},
{
name: "expired_past_ttl",
meta: CacheMetadata{CachedAt: time.Now().Add(-25 * time.Hour), TTLHours: 24},
expected: true,
},
{
name: "zero_ttl_defaults_24h_fresh",
meta: CacheMetadata{CachedAt: time.Now(), TTLHours: 0},
expected: false,
},
{
name: "zero_ttl_defaults_24h_expired",
meta: CacheMetadata{CachedAt: time.Now().Add(-25 * time.Hour), TTLHours: 0},
expected: true,
},
{
name: "exactly_at_ttl_boundary",
meta: CacheMetadata{CachedAt: time.Now().Add(-24 * time.Hour), TTLHours: 24},
expected: true, // at exactly TTL, treat as expired
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.meta.IsExpired()
if got != tt.expected {
t.Errorf("IsExpired() = %v, want %v (cachedAt=%v, ttl=%dh)",
got, tt.expected, tt.meta.CachedAt, tt.meta.TTLHours)
}
})
}
}

View file

@ -0,0 +1,241 @@
package crypto
// replay_test.go — Pre-fix tests for command replay attack surface on the agent side.
//
// These tests document the current (buggy) behaviour of the agent's command
// verification path. All tests use helpers defined in verification_test.go
// (generateKeyPair, signCommand, signCommandOld) which are in the same package.
//
// Each test is categorised:
//
// PASS-NOW / FAIL-AFTER-FIX — documents a bug as-is; flips to fail when fix is applied.
//
// Run: cd agent && go test ./internal/crypto/... -v -run TestReplay
// cd agent && go test ./internal/crypto/... -v -run TestOld
// cd agent && go test ./internal/crypto/... -v -run TestNew
// cd agent && go test ./internal/crypto/... -v -run TestSame
// cd agent && go test ./internal/crypto/... -v -run TestCross
import (
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// ---------------------------------------------------------------------------
// Test 2.1 — BUG F-3: Old-format commands are valid forever
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// VerifyCommand (crypto/verification.go:25) reconstructs the message as
// "{id}:{type}:{sha256(params)}" and verifies the Ed25519 signature.
// There is NO time check. A command signed 72 hours ago — or 72 years ago —
// passes without error. The test PASSES now (bug is present).
// After fix (add expiry to VerifyCommand or deprecate old format): FAILS.
// ---------------------------------------------------------------------------
func TestOldFormatReplayIsUnbounded(t *testing.T) {
// POST-FIX (F-3): Old-format commands with CreatedAt older than 48h are rejected.
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
// Old-format command created 72 hours ago
createdAt := time.Now().Add(-72 * time.Hour)
cmd := client.Command{
ID: "old-replay-cmd-72h",
Type: "install_updates",
Params: map[string]interface{}{"package": "nginx"},
SignedAt: nil,
CreatedAt: &createdAt,
}
cmd.Signature = signCommandOld(t, priv, &cmd)
// After F-3 fix: 72h old-format command must be rejected
err := v.VerifyCommand(cmd, pub)
if err == nil {
t.Error("F-3 FIX BROKEN: old-format command 72h old should be rejected, but passed")
}
t.Logf("F-3 FIXED: Old-format command created 72h ago correctly rejected: %v", err)
}
func TestOldFormatRecentCommandStillPasses(t *testing.T) {
// POST-FIX (F-3): Old-format commands WITHIN 48h still pass (backward compat).
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
// Old-format command created 12 hours ago (within 48h limit)
createdAt := time.Now().Add(-12 * time.Hour)
cmd := client.Command{
ID: "old-recent-cmd",
Type: "install_updates",
Params: map[string]interface{}{"package": "nginx"},
SignedAt: nil,
CreatedAt: &createdAt,
}
cmd.Signature = signCommandOld(t, priv, &cmd)
err := v.VerifyCommand(cmd, pub)
if err != nil {
t.Errorf("old-format command within 48h should pass: %v", err)
}
t.Log("F-3 BACKWARD COMPAT: Old-format command created 12h ago passes verification.")
}
// ---------------------------------------------------------------------------
// Test 2.2 — F-4: New-format commands can be replayed for 24 hours
//
// Category: PASS-NOW / MAY-REMAIN-PASSING-UNTIL-maxAge-IS-REDUCED
//
// VerifyCommandWithTimestamp allows commands signed up to commandMaxAge (24h)
// in the past. A captured command from 23 hours and 59 minutes ago still
// passes verification. This documents the replay window.
//
// Note: This test reflects an intentional (but generous) design decision.
// It will only flip to FAIL if commandMaxAge is reduced below 24h.
// ---------------------------------------------------------------------------
func TestNewFormatCommandCanBeReplayedWithin24Hours(t *testing.T) {
// POST-FIX (F-4): commandMaxAge reduced to 4h. Test updated to use 3h59m.
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
// Command signed almost 4 hours ago — still within the new 4h window.
signedAt := time.Now().UTC().Add(-3*time.Hour - 59*time.Minute)
cmd := client.Command{
ID: "replay-4h-cmd",
Type: "install_updates",
Params: map[string]interface{}{"package": "nginx"},
AgentID: "agent-replay-test",
}
cmd.SignedAt = &signedAt
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
if err != nil {
t.Fatalf("expected 3h59m old command to pass within 4h window, got: %v", err)
}
t.Log("F-4 FIXED: Command signed 3h59m ago passes VerifyCommandWithTimestamp with 4h window.")
t.Logf(" SignedAt: %v (window: 4h)", signedAt.Format(time.RFC3339))
}
func TestCommandBeyond4HoursIsRejected(t *testing.T) {
// POST-FIX (F-4): Commands older than 4h must be rejected.
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
signedAt := time.Now().UTC().Add(-4*time.Hour - 1*time.Minute)
cmd := client.Command{
ID: "expired-4h-cmd",
Type: "install_updates",
Params: map[string]interface{}{"package": "nginx"},
AgentID: "agent-replay-test",
}
cmd.SignedAt = &signedAt
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
if err == nil {
t.Error("expected 4h1m old command to be rejected, but it passed")
}
t.Logf("F-4 FIXED: Command signed 4h1m ago correctly rejected: %v", err)
}
// ---------------------------------------------------------------------------
// Test 2.3 — BUG F-2: The same command can be verified any number of times
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// VerifyCommandWithTimestamp is a pure function — given the same inputs it
// returns the same output every time. There is no nonce, no single-use token,
// and no agent-side deduplication. A replayed command passes verification
// identically on the second, third, and Nth call within the time window.
// ---------------------------------------------------------------------------
func TestSameCommandCanBeVerifiedTwice(t *testing.T) {
// POST-FIX (F-2): Deduplication is now at the ProcessCommand level,
// not at the VerifyCommandWithTimestamp level. The verifier is a pure
// function — it still returns success on repeated calls. The dedup
// is handled by CommandHandler.ProcessCommand's executedIDs set.
//
// This test documents that the VERIFIER allows repeated verification
// (which is correct — dedup is a higher-layer concern).
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
signedAt := time.Now().UTC()
cmd := client.Command{
ID: "no-nonce-cmd",
Type: "reboot",
Params: map[string]interface{}{},
AgentID: "agent-dedup-test",
}
cmd.SignedAt = &signedAt
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
// Verifier-level: still passes multiple times (pure function)
err1 := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
if err1 != nil {
t.Fatalf("first verification should pass: %v", err1)
}
err2 := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
if err2 != nil {
t.Fatalf("second verification should also pass at verifier level: %v", err2)
}
t.Log("F-2 NOTE: Verifier is a pure function — dedup is at ProcessCommand layer.")
t.Log("CommandHandler.ProcessCommand maintains executedIDs set for single-use enforcement.")
}
// ---------------------------------------------------------------------------
// Test 2.4 — BUG F-1: Signed message contains no agent binding
//
// Category: PASS-NOW / FAIL-AFTER-FIX
//
// The signed message format is: "{id}:{type}:{sha256(params)}:{timestamp}"
// None of these components are bound to a specific agent. The client.Command
// struct has no agent_id field at all — it is stripped before delivery.
// The same signature verifies regardless of which agent receives the command.
// ---------------------------------------------------------------------------
func TestCrossAgentSignatureVerifies(t *testing.T) {
// POST-FIX (F-1): agent_id is now in the signed message.
// A command signed for agent A must fail verification when presented
// with agent B's ID.
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
signedAt := time.Now().UTC()
agentA := "agent-aaa-111"
agentB := "agent-bbb-222"
// Sign command for agent A using v3 format
cmd := client.Command{
ID: "cross-agent-cmd",
Type: "install_updates",
Params: map[string]interface{}{"package": "nginx"},
AgentID: agentA,
}
cmd.SignedAt = &signedAt
cmd.Signature = signCommand(t, priv, &cmd, signedAt)
// Verify with correct agent A — should pass
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
if err != nil {
t.Fatalf("verification with correct agent A should pass, got: %v", err)
}
// Now try with agent B's ID — should FAIL
cmdForB := cmd
cmdForB.AgentID = agentB
err = v.VerifyCommandWithTimestamp(cmdForB, pub, 24*time.Hour, 5*time.Minute)
if err == nil {
t.Error("F-1 FIX BROKEN: cross-agent verification with agent B should FAIL but passed")
}
t.Logf("F-1 FIXED: Command signed for agent %q fails verification when presented as agent %q", agentA, agentB)
t.Log("The signature is now bound to the target agent_id in the v3 message format.")
}

View file

@ -0,0 +1,299 @@
package crypto
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// CommandVerifier handles Ed25519 signature verification for commands
type CommandVerifier struct{}
// NewCommandVerifier creates a new command verifier
func NewCommandVerifier() *CommandVerifier {
return &CommandVerifier{}
}
// oldFormatMaxAge is the maximum age for old-format commands (no signed_at).
// Phase 1 (F-3 fix): reject old-format commands older than 48h.
// Phase 2 (future): remove old-format fallback entirely after 90 days from migration 025 deployment.
const oldFormatMaxAge = 48 * time.Hour
// VerifyCommand verifies a command using the old signing format (no timestamp).
// Used for backward compatibility with commands signed before key rotation support.
// Format: "{id}:{command_type}:{sha256(params)}"
// F-3 fix: rejects commands older than 48h if CreatedAt is available.
func (v *CommandVerifier) VerifyCommand(cmd client.Command, serverPubKey ed25519.PublicKey) error {
// F-3 fix: check age using server-side created_at if available
if cmd.CreatedAt != nil {
age := time.Since(*cmd.CreatedAt)
if age > oldFormatMaxAge {
return fmt.Errorf("command too old: old-format command exceeds 48h age limit (created %v ago)", age.Round(time.Second))
}
}
if cmd.Signature == "" {
return fmt.Errorf("command missing signature")
}
sig, err := hex.DecodeString(cmd.Signature)
if err != nil {
return fmt.Errorf("invalid signature encoding: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(sig))
}
message, err := v.reconstructMessage(cmd)
if err != nil {
return fmt.Errorf("failed to reconstruct message: %w", err)
}
if !ed25519.Verify(serverPubKey, message, sig) {
return fmt.Errorf("signature verification failed")
}
return nil
}
// reconstructMessage recreates the signed message using the old format (no timestamp).
// Format: "{id}:{command_type}:{sha256(params)}"
func (v *CommandVerifier) reconstructMessage(cmd client.Command) ([]byte, error) {
paramsJSON, err := json.Marshal(cmd.Params)
if err != nil {
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
message := fmt.Sprintf("%s:%s:%s", cmd.ID, cmd.Type, paramsHashHex)
return []byte(message), nil
}
// reconstructMessageV3 recreates the signed message using v3 format (with agent_id + timestamp).
// Format: "{agent_id}:{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
func (v *CommandVerifier) reconstructMessageV3(cmd client.Command) ([]byte, error) {
if cmd.SignedAt == nil {
return nil, fmt.Errorf("command SignedAt is nil, cannot reconstruct v3 message")
}
if cmd.AgentID == "" {
return nil, fmt.Errorf("command AgentID is empty, cannot reconstruct v3 message")
}
paramsJSON, err := json.Marshal(cmd.Params)
if err != nil {
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
message := fmt.Sprintf("%s:%s:%s:%s:%d", cmd.AgentID, cmd.ID, cmd.Type, paramsHashHex, cmd.SignedAt.Unix())
return []byte(message), nil
}
// reconstructMessageWithTimestamp recreates the signed message using v2 format (timestamp, no agent_id).
// Format: "{id}:{command_type}:{sha256(params)}:{unix_timestamp}"
func (v *CommandVerifier) reconstructMessageWithTimestamp(cmd client.Command) ([]byte, error) {
if cmd.SignedAt == nil {
return nil, fmt.Errorf("command SignedAt is nil, cannot reconstruct timestamped message")
}
paramsJSON, err := json.Marshal(cmd.Params)
if err != nil {
return nil, fmt.Errorf("failed to marshal parameters: %w", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
message := fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, cmd.SignedAt.Unix())
return []byte(message), nil
}
// VerifyCommandWithTimestamp verifies a command signature AND validates the signing timestamp.
// Rejects commands signed more than maxAge in the past, or more than clockSkew in the future.
// Uses the new timestamped message format.
// If cmd.SignedAt is nil, falls back to the old (no-timestamp) verification format for backward compatibility.
//
// The default maxAge used by command_handler.go is 4 hours (reduced from 24h in A-2 fix F-4).
// This balances security (shorter replay window) against operational flexibility (agents
// polling every few minutes have ample time to receive and verify commands).
// See commandMaxAge constant in orchestrator/command_handler.go.
func (v *CommandVerifier) VerifyCommandWithTimestamp(
cmd client.Command,
serverPubKey ed25519.PublicKey,
maxAge time.Duration,
clockSkew time.Duration,
) error {
if cmd.SignedAt == nil {
// No timestamp — fall back to old format (backward compat)
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "verification", "deprecated command format: oldest format, no signed_at", map[string]interface{}{
"command_id": cmd.ID,
})
}
return v.VerifyCommand(cmd, serverPubKey)
}
// Validate timestamp window
now := time.Now().UTC()
age := now.Sub(*cmd.SignedAt)
if age > maxAge {
return fmt.Errorf("command timestamp too old: signed %v ago (max %v)", age.Round(time.Second), maxAge)
}
if age < -clockSkew {
return fmt.Errorf("command timestamp is in the future: %v ahead (max skew %v)", (-age).Round(time.Second), clockSkew)
}
// Verify signature
if cmd.Signature == "" {
return fmt.Errorf("command missing signature")
}
sig, err := hex.DecodeString(cmd.Signature)
if err != nil {
return fmt.Errorf("invalid signature encoding: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(sig))
}
// Try v3 format first (with agent_id) if AgentID is present
if cmd.AgentID != "" {
message, err := v.reconstructMessageV3(cmd)
if err != nil {
return fmt.Errorf("failed to reconstruct v3 message: %w", err)
}
if ed25519.Verify(serverPubKey, message, sig) {
return nil // v3 verification succeeded
}
// v3 failed — try v2 as fallback (server may not have been upgraded yet)
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "verification", "v3 verification failed, trying v2", map[string]interface{}{
"command_id": cmd.ID,
})
}
}
// v2 format: timestamp but no agent_id (backward compat)
if cmd.AgentID == "" {
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "verification", "deprecated command format: v2, no agent_id", map[string]interface{}{
"command_id": cmd.ID,
})
}
}
message, err := v.reconstructMessageWithTimestamp(cmd)
if err != nil {
return fmt.Errorf("failed to reconstruct timestamped message: %w", err)
}
if !ed25519.Verify(serverPubKey, message, sig) {
return fmt.Errorf("signature verification failed")
}
return nil
}
// CheckKeyRotation checks if the key_id in a command is cached locally.
// If not cached, it fetches all active keys from the server and caches them.
// Returns the correct public key to use for verifying this command.
func (v *CommandVerifier) CheckKeyRotation(keyID string, serverURL string) (ed25519.PublicKey, bool, error) {
if keyID == "" {
// No key_id in command — backward compat: use primary cached key
key, err := LoadCachedPublicKey()
return key, false, err
}
// Check if this key is already cached
if IsKeyIDCached(keyID) {
key, err := LoadCachedPublicKeyByID(keyID)
return key, false, err
}
// Key not cached — fetch all active keys from server
if teeLogger != nil {
teeLogger.Info("agent", "crypto", "verification", "key not cached, fetching active keys from server", map[string]interface{}{
"key_id": keyID,
})
}
entries, err := FetchAndCacheAllActiveKeys(serverURL)
if err != nil {
// Forward-only (SEC-028): the active-set fetch failed, but trusting the
// primary cache here with NO age bound is a wider hole than the primary
// fetch path (FetchAndCacheServerPublicKey) closes on its own. Apply the
// same bounded-stale ceiling: serve the primary only within the window,
// fail closed past it. A primary the server rotated OUT must not verify a
// key_id'd command just because /public-keys is unreachable.
key, loadErr := LoadCachedPublicKey()
if loadErr != nil {
return nil, false, fmt.Errorf("key %s not cached and active-set fetch failed: fetch=%v, load=%v", keyID, err, loadErr)
}
meta, metaErr := loadCacheMetadata()
if metaErr != nil {
return nil, false, fmt.Errorf("active-set fetch failed and primary cache age unknown (no metadata); refusing: %w", err)
}
age := time.Since(meta.CachedAt)
window := staleKeyMaxAge()
if age > window {
if teeLogger != nil {
teeLogger.Error("agent", "crypto", "verification", "active-set fetch failed and primary cache stale; refusing key_id'd command", map[string]interface{}{
"key_id": keyID,
"cache_age": age.Round(time.Hour).String(),
"max_stale": window.String(),
"error": err.Error(),
})
}
return nil, false, fmt.Errorf("active-set fetch failed and primary cache stale (age %s > %s); refusing: %w",
age.Round(time.Hour), window, err)
}
if teeLogger != nil {
teeLogger.Warning("agent", "crypto", "verification", "active-set fetch failed, serving bounded-stale primary", map[string]interface{}{
"key_id": keyID,
"cache_age": age.Round(time.Minute).String(),
"max_stale": window.String(),
"error": err.Error(),
})
}
return key, false, nil
}
// Check if we got the requested key
for _, entry := range entries {
if entry.KeyID == keyID {
key, err := LoadCachedPublicKeyByID(keyID)
return key, true, err
}
}
// Requested key_id was fetched but is not in the server's active set. A key
// the server has rotated OUT is dead — forward-only doctrine refuses it
// rather than silently falling back to the primary key (SEC-028). The caller
// surfaces this as a command-verification failure.
if teeLogger != nil {
teeLogger.Error("agent", "crypto", "verification", "command key_id not in server active set, refusing", map[string]interface{}{
"key_id": keyID,
})
}
return nil, false, fmt.Errorf("key %s not in server active set", keyID)
}
// VerifyCommandBatch verifies multiple commands efficiently
func (v *CommandVerifier) VerifyCommandBatch(
commands []client.Command,
serverPubKey ed25519.PublicKey,
) []error {
errors := make([]error, len(commands))
for i, cmd := range commands {
errors[i] = v.VerifyCommand(cmd, serverPubKey)
}
return errors
}
// ExtractCommandIDFromSignature attempts to verify a signature and returns the command ID
func (v *CommandVerifier) ExtractCommandIDFromSignature(
signature string,
expectedMessage string,
serverPubKey ed25519.PublicKey,
) (string, error) {
sig, err := hex.DecodeString(signature)
if err != nil {
return "", fmt.Errorf("invalid signature encoding: %w", err)
}
if !ed25519.Verify(serverPubKey, []byte(expectedMessage), sig) {
return "", fmt.Errorf("signature verification failed")
}
return "", nil
}

View file

@ -0,0 +1,216 @@
package crypto
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// signCommand is a test helper that signs a command using the v3 format (with agent_id).
// Format: "{agent_id}:{id}:{type}:{sha256(params)}:{unix_timestamp}"
// Falls back to v2 format if cmd.AgentID is empty (for backward compat tests).
func signCommand(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command, signedAt time.Time) string {
t.Helper()
paramsJSON, err := json.Marshal(cmd.Params)
if err != nil {
t.Fatalf("failed to marshal params: %v", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
var message string
if cmd.AgentID != "" {
// v3 format with agent_id
message = fmt.Sprintf("%s:%s:%s:%s:%d", cmd.AgentID, cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
} else {
// v2 format without agent_id (backward compat)
message = fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
}
sig := ed25519.Sign(privKey, []byte(message))
return hex.EncodeToString(sig)
}
// signCommandV2 explicitly signs using v2 format (no agent_id) for backward compat tests.
func signCommandV2(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command, signedAt time.Time) string {
t.Helper()
paramsJSON, err := json.Marshal(cmd.Params)
if err != nil {
t.Fatalf("failed to marshal params: %v", err)
}
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
message := fmt.Sprintf("%s:%s:%s:%d", cmd.ID, cmd.Type, paramsHashHex, signedAt.Unix())
sig := ed25519.Sign(privKey, []byte(message))
return hex.EncodeToString(sig)
}
// signCommandOld is a test helper that signs using the old format (no timestamp).
// Format: "{id}:{type}:{sha256(params)}"
func signCommandOld(t *testing.T, privKey ed25519.PrivateKey, cmd *client.Command) string {
t.Helper()
paramsJSON, _ := json.Marshal(cmd.Params)
paramsHash := sha256.Sum256(paramsJSON)
paramsHashHex := hex.EncodeToString(paramsHash[:])
message := fmt.Sprintf("%s:%s:%s", cmd.ID, cmd.Type, paramsHashHex)
sig := ed25519.Sign(privKey, []byte(message))
return hex.EncodeToString(sig)
}
func generateKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("failed to generate key pair: %v", err)
}
return pub, priv
}
func TestVerifyCommandWithTimestamp_ValidRecent(t *testing.T) {
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
now := time.Now().UTC()
cmd := client.Command{
ID: "test-cmd-1",
Type: "scan_updates",
Params: map[string]interface{}{"target": "apt"},
AgentID: "agent-001",
}
cmd.SignedAt = &now
cmd.Signature = signCommand(t, priv, &cmd, now)
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
if err != nil {
t.Errorf("expected valid recent command to pass, got: %v", err)
}
}
func TestVerifyCommandWithTimestamp_TooOld(t *testing.T) {
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
oldTime := time.Now().UTC().Add(-2 * time.Hour)
cmd := client.Command{
ID: "test-cmd-2",
Type: "scan_updates",
Params: map[string]interface{}{},
AgentID: "agent-002",
}
cmd.SignedAt = &oldTime
cmd.Signature = signCommand(t, priv, &cmd, oldTime)
// With maxAge of 1 hour — should fail
err := v.VerifyCommandWithTimestamp(cmd, pub, 1*time.Hour, 5*time.Minute)
if err == nil {
t.Error("expected old command to fail timestamp check, but it passed")
}
}
func TestVerifyCommandWithTimestamp_FutureBeyondSkew(t *testing.T) {
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
futureTime := time.Now().UTC().Add(10 * time.Minute)
cmd := client.Command{
ID: "test-cmd-3",
Type: "scan_updates",
Params: map[string]interface{}{},
AgentID: "agent-003",
}
cmd.SignedAt = &futureTime
cmd.Signature = signCommand(t, priv, &cmd, futureTime)
// With clockSkew of 5 min — should fail (10 min future)
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
if err == nil {
t.Error("expected future-dated command to fail, but it passed")
}
}
func TestVerifyCommandWithTimestamp_FutureWithinSkew(t *testing.T) {
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
futureTime := time.Now().UTC().Add(2 * time.Minute) // within 5 min skew
cmd := client.Command{
ID: "test-cmd-4",
Type: "scan_updates",
Params: map[string]interface{}{},
AgentID: "agent-004",
}
cmd.SignedAt = &futureTime
cmd.Signature = signCommand(t, priv, &cmd, futureTime)
err := v.VerifyCommandWithTimestamp(cmd, pub, 24*time.Hour, 5*time.Minute)
if err != nil {
t.Errorf("expected command within clock skew to pass, got: %v", err)
}
}
func TestVerifyCommandWithTimestamp_BackwardCompatNoTimestamp(t *testing.T) {
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
// Set CreatedAt to recent time so F-3 48h check passes
createdAt := time.Now().Add(-1 * time.Hour)
cmd := client.Command{
ID: "test-cmd-5",
Type: "scan_updates",
Params: map[string]interface{}{"pkg": "nginx"},
SignedAt: nil, // no timestamp — old server
CreatedAt: &createdAt,
}
cmd.Signature = signCommandOld(t, priv, &cmd)
// Should fall back to old verification and succeed (within 48h)
err := v.VerifyCommandWithTimestamp(cmd, pub, 4*time.Hour, 5*time.Minute)
if err != nil {
t.Errorf("expected backward-compat (no timestamp) command to pass, got: %v", err)
}
}
func TestVerifyCommandWithTimestamp_WrongKey(t *testing.T) {
_, priv := generateKeyPair(t)
wrongPub, _ := generateKeyPair(t)
v := NewCommandVerifier()
now := time.Now().UTC()
cmd := client.Command{
ID: "test-cmd-6",
Type: "scan_updates",
Params: map[string]interface{}{},
AgentID: "agent-006",
}
cmd.SignedAt = &now
cmd.Signature = signCommand(t, priv, &cmd, now)
err := v.VerifyCommandWithTimestamp(cmd, wrongPub, 24*time.Hour, 5*time.Minute)
if err == nil {
t.Error("expected wrong-key verification to fail, but it passed")
}
}
func TestVerifyCommand_BackwardCompat(t *testing.T) {
pub, priv := generateKeyPair(t)
v := NewCommandVerifier()
// Set CreatedAt to recent time so F-3 48h check passes
createdAt := time.Now().Add(-1 * time.Hour)
cmd := client.Command{
ID: "test-cmd-7",
Type: "install_updates",
Params: map[string]interface{}{"package": "nginx", "version": "1.20.0"},
CreatedAt: &createdAt,
}
cmd.Signature = signCommandOld(t, priv, &cmd)
if err := v.VerifyCommand(cmd, pub); err != nil {
t.Errorf("expected old-format verification to pass, got: %v", err)
}
}

View file

@ -0,0 +1,240 @@
// Package desktop manages the native Qt/QML local-machine operations console.
// The agent service spawns the desktop binary as a child process when a desktop
// session is available. The binary connects back to the agent's local API socket
// and presents Agent-owned machine state and bounded operator intent.
package desktop
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
)
// Manager handles spawning and monitoring the desktop app process.
type Manager struct {
binPath string
enabled bool
maxRestarts int
restartDelay time.Duration
mu sync.Mutex
cmd *exec.Cmd
cancel context.CancelFunc
stopped bool
lastHealth healthReport
}
// healthReport is Desktop's most recent POST /v1/desktop self-report. On
// Linux Desktop is launched by XDG autostart, not by this manager, so the
// spawned-process state is always empty there — the health report is the only
// liveness and version signal the agent has.
type healthReport struct {
version string
windowOpen bool
reportedAt time.Time
}
// healthFreshness is how long a health report counts as proof of a live Desktop.
// Desktop reports every 30s; three missed beats means it is gone.
const healthFreshness = 90 * time.Second
// HealthSnapshot is the fleet-reportable desktop component state.
type HealthSnapshot struct {
Installed bool `json:"installed"`
Running bool `json:"running"`
Version string `json:"version,omitempty"`
WindowOpen bool `json:"window_open,omitempty"`
LastReport string `json:"last_report,omitempty"`
}
// NewManager creates a desktop manager. binPath is the path to the redflag-desktop
// binary (empty string = auto-detect alongside the agent binary).
func NewManager(binPath string, enabled bool, maxRestarts, restartDelaySec int) *Manager {
if binPath == "" {
binPath = autoDetectBinary()
}
if restartDelaySec <= 0 {
restartDelaySec = 5
}
return &Manager{
binPath: binPath,
enabled: enabled,
maxRestarts: maxRestarts,
restartDelay: time.Duration(restartDelaySec) * time.Second,
}
}
// autoDetectBinary finds the desktop binary alongside the agent binary.
func autoDetectBinary() string {
execPath, err := os.Executable()
if err != nil {
return ""
}
dir := filepath.Dir(execPath)
name := "redflag-desktop"
if runtime.GOOS == "windows" {
name = "redflag-desktop.exe"
}
return filepath.Join(dir, name)
}
// IsAvailable checks if the desktop binary exists and a desktop session is detectable.
func (m *Manager) IsAvailable() bool {
if m.binPath == "" {
return false
}
info, err := os.Stat(m.binPath)
if err != nil || info.IsDir() {
return false
}
return hasDesktopSession()
}
// Start begins managing the desktop process. It blocks until Stop() is called
// or the context is cancelled. Spawns the binary, restarts on crash up to
// maxRestarts times.
func (m *Manager) Start(ctx context.Context) {
if !m.enabled {
log.Printf("[INFO] [agent] [desktop] disabled by config")
return
}
if !m.IsAvailable() {
log.Printf("[INFO] [agent] [desktop] not available — binary=%s session_detected=false", m.binPath)
return
}
log.Printf("[INFO] [agent] [desktop] starting binary=%s", m.binPath)
m.mu.Lock()
ctx, m.cancel = context.WithCancel(ctx)
m.stopped = false
m.mu.Unlock()
restarts := 0
for {
select {
case <-ctx.Done():
log.Printf("[INFO] [agent] [desktop] stopped (context cancelled)")
return
default:
}
if m.maxRestarts > 0 && restarts >= m.maxRestarts {
log.Printf("[WARNING] [agent] [desktop] max restarts reached (%d), giving up", m.maxRestarts)
return
}
err := m.run(ctx)
if err == nil {
// Clean exit — don't restart.
log.Printf("[INFO] [agent] [desktop] exited cleanly")
return
}
restarts++
log.Printf("[WARNING] [agent] [desktop] process exited with error: %v (restart %d)", err, restarts)
select {
case <-ctx.Done():
return
case <-time.After(m.restartDelay):
}
}
}
// run spawns the desktop binary and waits for it to exit.
func (m *Manager) run(ctx context.Context) error {
m.mu.Lock()
cmd := exec.CommandContext(ctx, m.binPath)
m.cmd = cmd
m.mu.Unlock()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start desktop: %w", err)
}
log.Printf("[INFO] [agent] [desktop] spawned pid=%d", cmd.Process.Pid)
return cmd.Wait()
}
// Stop terminates the desktop process if running.
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if m.cancel != nil {
m.cancel()
}
m.stopped = true
if m.cmd != nil && m.cmd.Process != nil {
log.Printf("[INFO] [agent] [desktop] terminating pid=%d", m.cmd.Process.Pid)
m.cmd.Process.Kill()
}
}
// Status returns current desktop process state.
func (m *Manager) Status() (running bool, pid int) {
m.mu.Lock()
defer m.mu.Unlock()
if m.cmd == nil || m.cmd.Process == nil {
return false, 0
}
// Check if process is still alive.
if m.cmd.ProcessState != nil && m.cmd.ProcessState.Exited() {
return false, 0
}
return true, m.cmd.Process.Pid
}
// RecordHealth stores Desktop's self-report (POST /v1/desktop via localapi).
func (m *Manager) RecordHealth(version string, windowOpen bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.lastHealth = healthReport{
version: version,
windowOpen: windowOpen,
reportedAt: time.Now().UTC(),
}
}
// Health returns the desktop component state for fleet reporting. Running is
// true when this manager spawned a live process (Windows) or when a health
// report landed within the freshness window (Linux autostart path).
func (m *Manager) Health() HealthSnapshot {
running, _ := m.Status()
m.mu.Lock()
last := m.lastHealth
m.mu.Unlock()
snap := HealthSnapshot{Running: running}
if m.binPath != "" {
if info, err := os.Stat(m.binPath); err == nil && !info.IsDir() {
snap.Installed = true
}
}
if !last.reportedAt.IsZero() {
snap.Version = last.version
snap.WindowOpen = last.windowOpen
snap.LastReport = last.reportedAt.Format(time.RFC3339)
if time.Since(last.reportedAt) <= healthFreshness {
snap.Running = true
}
}
return snap
}

View file

@ -0,0 +1,55 @@
package desktop
import (
"os"
"strings"
)
// hasDesktopSession checks if a desktop session is available on Linux.
// Looks for DISPLAY or WAYLAND_DISPLAY in the environment, which indicates
// an X11 or Wayland session is active.
func hasDesktopSession() bool {
// Check standard environment variables.
if os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" {
return true
}
// Fallback: scan /proc for a running desktop session (the agent service
// may not inherit DISPLAY from systemd).
return findDesktopSessionInProc()
}
// findDesktopSessionInProc scans /proc/*/environ for DISPLAY or WAYLAND_DISPLAY.
// Returns true if any user process has a desktop session active.
func findDesktopSessionInProc() bool {
entries, err := os.ReadDir("/proc")
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Only check numeric (PID) directories.
if entry.Name()[0] < '0' || entry.Name()[0] > '9' {
continue
}
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
if err != nil {
continue
}
for _, v := range strings.Split(string(data), "\x00") {
if strings.HasPrefix(v, "DISPLAY=") && len(v) > 8 {
return true
}
if strings.HasPrefix(v, "WAYLAND_DISPLAY=") && len(v) > 17 {
return true
}
}
}
return false
}

View file

@ -0,0 +1,11 @@
//go:build !linux && !windows
package desktop
import "os"
// hasDesktopSession checks if a desktop session is available on other platforms.
// Default: check for DISPLAY environment variable.
func hasDesktopSession() bool {
return os.Getenv("DISPLAY") != ""
}

View file

@ -0,0 +1,27 @@
package desktop
import (
"os/exec"
"strings"
)
// hasDesktopSession checks if a desktop session is available on Windows.
// Looks for an active console session via query session.
func hasDesktopSession() bool {
// query session lists all sessions. An active console session with
// a logged-in user means a desktop is available.
out, err := exec.Command("query", "session").Output()
if err != nil {
return false
}
lines := strings.Split(string(out), "\n")
for _, line := range lines {
// Active console sessions have "Active" in the state column.
if strings.Contains(line, "Active") && strings.Contains(line, "console") {
return true
}
}
return false
}

View file

@ -0,0 +1,43 @@
package display
// ethos_exempt_test.go — Exemption documentation for terminal display emoji.
// D-2: display/terminal.go emoji is EXEMPT from ETHOS #1.
// This is intentional user-facing terminal UI, not log output.
// Do NOT modify this file in the D-2 fix pass.
import (
"os"
"testing"
)
func TestTerminalDisplayIsExemptFromEthos(t *testing.T) {
// D-2: display/terminal.go emoji is EXEMPT.
// This is intentional user-facing terminal UI.
// ETHOS #1 applies to log statements, not UI rendering.
_, err := os.Stat("terminal.go")
if err != nil {
t.Skip("[INFO] [agent] [display] terminal.go not found")
}
content, err := os.ReadFile("terminal.go")
if err != nil {
t.Fatalf("failed to read terminal.go: %v", err)
}
// Confirm emoji IS present (intentional)
hasEmoji := false
for _, r := range string(content) {
if r >= 0x1F300 || (r >= 0x2600 && r <= 0x27BF) {
hasEmoji = true
break
}
}
if !hasEmoji {
t.Log("[INFO] [agent] [display] terminal.go has no emoji (ok — may have been cleaned)")
} else {
t.Log("[INFO] [agent] [display] terminal.go has emoji (EXEMPT — intentional terminal UI)")
}
t.Log("[INFO] [agent] [display] EXEMPTION: display/terminal.go emoji is intentional UI, not log output")
}

View file

@ -0,0 +1,401 @@
package display
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
)
// Color codes for terminal output
const (
ColorReset = "\033[0m"
ColorRed = "\033[31m"
ColorGreen = "\033[32m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorPurple = "\033[35m"
ColorCyan = "\033[36m"
ColorWhite = "\033[37m"
ColorBold = "\033[1m"
)
// SeverityColors maps severity levels to colors
var SeverityColors = map[string]string{
"critical": ColorRed,
"high": ColorRed,
"medium": ColorYellow,
"moderate": ColorYellow,
"low": ColorGreen,
"info": ColorBlue,
}
// PrintScanResults displays scan results in a pretty format
func PrintScanResults(updates []client.UpdateReportItem, exportFormat string) error {
// Handle export formats
if exportFormat != "" {
return exportResults(updates, exportFormat)
}
// Count updates by type
aptCount := 0
dockerCount := 0
otherCount := 0
for _, update := range updates {
switch update.PackageType {
case "apt":
aptCount++
case "docker":
dockerCount++
default:
otherCount++
}
}
// Header
fmt.Printf("%s🚩 RedFlag Update Scan Results%s\n", ColorBold+ColorRed, ColorReset)
fmt.Printf("%s%sScan completed: %s%s\n", ColorBold, ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
fmt.Println()
// Summary
if len(updates) == 0 {
fmt.Printf("%s✅ No updates available - system is up to date!%s\n", ColorBold+ColorGreen, ColorReset)
return nil
}
fmt.Printf("%s📊 Summary:%s\n", ColorBold+ColorBlue, ColorReset)
fmt.Printf(" Total updates: %s%d%s\n", ColorBold+ColorYellow, len(updates), ColorReset)
if aptCount > 0 {
fmt.Printf(" APT packages: %s%d%s\n", ColorBold+ColorCyan, aptCount, ColorReset)
}
if dockerCount > 0 {
fmt.Printf(" Docker images: %s%d%s\n", ColorBold+ColorCyan, dockerCount, ColorReset)
}
if otherCount > 0 {
fmt.Printf(" Other: %s%d%s\n", ColorBold+ColorCyan, otherCount, ColorReset)
}
fmt.Println()
// Group by package type
if aptCount > 0 {
printAPTUpdates(updates)
}
if dockerCount > 0 {
printDockerUpdates(updates)
}
if otherCount > 0 {
printOtherUpdates(updates)
}
// Footer
fmt.Println()
fmt.Printf("%s💡 Tip: Use --list-updates for detailed information or --export=json for automation%s\n", ColorBold+ColorYellow, ColorReset)
return nil
}
// printAPTUpdates displays APT package updates
func printAPTUpdates(updates []client.UpdateReportItem) {
fmt.Printf("%s📦 APT Package Updates%s\n", ColorBold+ColorBlue, ColorReset)
fmt.Println(strings.Repeat("─", 50))
for _, update := range updates {
if update.PackageType != "apt" {
continue
}
severityColor := getSeverityColor(update.Severity)
packageIcon := getPackageIcon(update.Severity)
fmt.Printf("%s %s%s%s\n", packageIcon, ColorBold, update.PackageName, ColorReset)
fmt.Printf(" Version: %s→%s\n",
getVersionColor(update.CurrentVersion),
getVersionColor(update.AvailableVersion))
if update.Severity != "" {
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
}
if update.PackageDescription != "" {
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
}
if len(update.CVEList) > 0 {
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
}
if update.RepositorySource != "" {
fmt.Printf(" Source: %s\n", update.RepositorySource)
}
if update.SizeBytes > 0 {
fmt.Printf(" Size: %s\n", formatBytes(update.SizeBytes))
}
fmt.Println()
}
}
// printDockerUpdates displays Docker image updates
func printDockerUpdates(updates []client.UpdateReportItem) {
fmt.Printf("%s🐳 Docker Image Updates%s\n", ColorBold+ColorBlue, ColorReset)
fmt.Println(strings.Repeat("─", 50))
for _, update := range updates {
if update.PackageType != "docker" {
continue
}
severityColor := getSeverityColor(update.Severity)
imageIcon := "🐳"
fmt.Printf("%s %s%s%s\n", imageIcon, ColorBold, update.PackageName, ColorReset)
if update.Severity != "" {
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
}
// Show digest comparison if available
if update.CurrentVersion != "" && update.AvailableVersion != "" {
fmt.Printf(" Digest: %s→%s\n",
truncateString(update.CurrentVersion, 12),
truncateString(update.AvailableVersion, 12))
}
if update.PackageDescription != "" {
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
}
if len(update.CVEList) > 0 {
fmt.Printf(" CVEs: %s\n", strings.Join(update.CVEList, ", "))
}
fmt.Println()
}
}
// printOtherUpdates displays updates from other package managers
func printOtherUpdates(updates []client.UpdateReportItem) {
fmt.Printf("%s📋 Other Updates%s\n", ColorBold+ColorBlue, ColorReset)
fmt.Println(strings.Repeat("─", 50))
for _, update := range updates {
if update.PackageType == "apt" || update.PackageType == "docker" {
continue
}
severityColor := getSeverityColor(update.Severity)
packageIcon := "📦"
fmt.Printf("%s %s%s%s (%s)\n", packageIcon, ColorBold, update.PackageName, ColorReset, update.PackageType)
fmt.Printf(" Version: %s→%s\n",
getVersionColor(update.CurrentVersion),
getVersionColor(update.AvailableVersion))
if update.Severity != "" {
fmt.Printf(" Severity: %s%s%s\n", severityColor, update.Severity, ColorReset)
}
if update.PackageDescription != "" {
fmt.Printf(" Description: %s\n", truncateString(update.PackageDescription, 60))
}
fmt.Println()
}
}
// PrintDetailedUpdates shows full details for all updates
func PrintDetailedUpdates(updates []client.UpdateReportItem, exportFormat string) error {
// Handle export formats
if exportFormat != "" {
return exportResults(updates, exportFormat)
}
fmt.Printf("%s🔍 Detailed Update Information%s\n", ColorBold+ColorPurple, ColorReset)
fmt.Printf("%sGenerated: %s%s\n\n", ColorCyan, time.Now().Format("2006-01-02 15:04:05"), ColorReset)
if len(updates) == 0 {
fmt.Printf("%s✅ No updates available%s\n", ColorBold+ColorGreen, ColorReset)
return nil
}
for i, update := range updates {
fmt.Printf("%sUpdate #%d%s\n", ColorBold+ColorYellow, i+1, ColorReset)
fmt.Println(strings.Repeat("═", 60))
fmt.Printf("%sPackage:%s %s\n", ColorBold, ColorReset, update.PackageName)
fmt.Printf("%sType:%s %s\n", ColorBold, ColorReset, update.PackageType)
fmt.Printf("%sCurrent Version:%s %s\n", ColorBold, ColorReset, update.CurrentVersion)
fmt.Printf("%sAvailable Version:%s %s\n", ColorBold, ColorReset, update.AvailableVersion)
if update.Severity != "" {
severityColor := getSeverityColor(update.Severity)
fmt.Printf("%sSeverity:%s %s%s%s\n", ColorBold, ColorReset, severityColor, update.Severity, ColorReset)
}
if update.PackageDescription != "" {
fmt.Printf("%sDescription:%s %s\n", ColorBold, ColorReset, update.PackageDescription)
}
if len(update.CVEList) > 0 {
fmt.Printf("%sCVE List:%s %s\n", ColorBold, ColorReset, strings.Join(update.CVEList, ", "))
}
if update.KBID != "" {
fmt.Printf("%sKB Article:%s %s\n", ColorBold, ColorReset, update.KBID)
}
if update.RepositorySource != "" {
fmt.Printf("%sRepository:%s %s\n", ColorBold, ColorReset, update.RepositorySource)
}
if update.SizeBytes > 0 {
fmt.Printf("%sSize:%s %s\n", ColorBold, ColorReset, formatBytes(update.SizeBytes))
}
if len(update.Metadata) > 0 {
fmt.Printf("%sMetadata:%s\n", ColorBold, ColorReset)
for key, value := range update.Metadata {
fmt.Printf(" %s: %v\n", key, value)
}
}
fmt.Println()
}
return nil
}
// PrintAgentStatus displays agent status information
func PrintAgentStatus(agentID string, serverURL string, lastCheckIn time.Time, lastScan time.Time, updateCount int, agentStatus string) {
fmt.Printf("%s🚩 RedFlag Agent Status%s\n", ColorBold+ColorRed, ColorReset)
fmt.Println(strings.Repeat("─", 40))
fmt.Printf("%sAgent ID:%s %s\n", ColorBold, ColorReset, agentID)
fmt.Printf("%sServer:%s %s\n", ColorBold, ColorReset, serverURL)
fmt.Printf("%sStatus:%s %s%s%s\n", ColorBold, ColorReset, getSeverityColor(agentStatus), agentStatus, ColorReset)
if !lastCheckIn.IsZero() {
fmt.Printf("%sLast Check-in:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastCheckIn))
} else {
fmt.Printf("%sLast Check-in:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
}
if !lastScan.IsZero() {
fmt.Printf("%sLast Scan:%s %s\n", ColorBold, ColorReset, formatTimeSince(lastScan))
fmt.Printf("%sUpdates Found:%s %s%d%s\n", ColorBold, ColorReset, ColorYellow, updateCount, ColorReset)
} else {
fmt.Printf("%sLast Scan:%s %sNever%s\n", ColorBold, ColorReset, ColorYellow, ColorReset)
}
fmt.Println()
}
// Helper functions
func getSeverityColor(severity string) string {
if color, ok := SeverityColors[severity]; ok {
return color
}
return ColorWhite
}
func getPackageIcon(severity string) string {
switch strings.ToLower(severity) {
case "critical", "high":
return "🔴"
case "medium", "moderate":
return "🟡"
case "low":
return "🟢"
default:
return "🔵"
}
}
func getVersionColor(version string) string {
if version == "" {
return ColorRed + "unknown" + ColorReset
}
return ColorCyan + version + ColorReset
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
func formatTimeSince(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return fmt.Sprintf("%d seconds ago", int(duration.Seconds()))
} else if duration < time.Hour {
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
} else if duration < 24*time.Hour {
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
} else {
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
}
}
func exportResults(updates []client.UpdateReportItem, format string) error {
switch strings.ToLower(format) {
case "json":
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(updates)
case "csv":
return exportCSV(updates)
default:
return fmt.Errorf("unsupported export format: %s (supported: json, csv)", format)
}
}
func exportCSV(updates []client.UpdateReportItem) error {
// Print CSV header
fmt.Println("PackageType,PackageName,CurrentVersion,AvailableVersion,Severity,CVEList,Description,SizeBytes")
// Print each update as CSV row
for _, update := range updates {
cveList := strings.Join(update.CVEList, ";")
description := strings.ReplaceAll(update.PackageDescription, ",", ";")
description = strings.ReplaceAll(description, "\n", " ")
fmt.Printf("%s,%s,%s,%s,%s,%s,%s,%d\n",
update.PackageType,
update.PackageName,
update.CurrentVersion,
update.AvailableVersion,
update.Severity,
cveList,
description,
update.SizeBytes,
)
}
return nil
}

View file

@ -0,0 +1,247 @@
package event
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/gofrs/uuid/v5"
"sync"
"github.com/Fimeg/RedFlag/agent/internal/models"
)
const (
defaultMaxBufferSize = 1000 // Max events to buffer
)
// Buffer handles local event buffering for offline resilience
type Buffer struct {
filePath string
maxSize int
mu sync.Mutex
}
// NewBuffer creates a new event buffer with the specified file path
func NewBuffer(filePath string) *Buffer {
return &Buffer{
filePath: filePath,
maxSize: defaultMaxBufferSize,
}
}
// BufferEvent saves an event to the local buffer file
func (b *Buffer) BufferEvent(event *models.SystemEvent) error {
b.mu.Lock()
defer b.mu.Unlock()
// Ensure event has an ID
if event.ID == uuid.Nil {
return fmt.Errorf("event ID cannot be nil")
}
// Create directory if needed
dir := filepath.Dir(b.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create buffer directory: %w", err)
}
// Read existing buffer
var events []*models.SystemEvent
if data, err := os.ReadFile(b.filePath); err == nil {
if err := json.Unmarshal(data, &events); err != nil {
// If we can't unmarshal, start fresh
events = []*models.SystemEvent{}
}
}
// Append new event
events = append(events, event)
// Desktop history survives successful fleet delivery. Seed it from the
// pending queue on upgrade, then deduplicate retries by the event identity.
historyErr := b.retainHistoryLocked(events)
// Keep only last N events if buffer too large (circular buffer)
if len(events) > b.maxSize {
events = events[len(events)-b.maxSize:]
}
// Write back to file
data, err := json.Marshal(events)
if err != nil {
return fmt.Errorf("failed to marshal events: %w", err)
}
if err := os.WriteFile(b.filePath, data, 0644); err != nil {
return fmt.Errorf("failed to write buffer file: %w", err)
}
return historyErr
}
const localHistoryLimit = 5000
func (b *Buffer) historyPath() string { return b.filePath + ".history.json" }
func (b *Buffer) retainHistoryLocked(pending []*models.SystemEvent) error {
var history []*models.SystemEvent
data, err := os.ReadFile(b.historyPath())
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read local history: %w", err)
}
if err == nil {
if err := json.Unmarshal(data, &history); err != nil {
return fmt.Errorf("decode local history: %w", err)
}
}
seen := make(map[uuid.UUID]bool, len(history))
for _, e := range history {
if e != nil {
seen[e.ID] = true
}
}
for _, e := range pending {
if e != nil && !seen[e.ID] {
history = append(history, e)
seen[e.ID] = true
}
}
if len(history) > localHistoryLimit {
history = history[len(history)-localHistoryLimit:]
}
data, err = json.Marshal(history)
if err != nil {
return fmt.Errorf("encode local history: %w", err)
}
f, err := os.CreateTemp(filepath.Dir(b.filePath), ".history-*")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(data); err != nil {
f.Close()
return err
}
if err = f.Sync(); err != nil {
f.Close()
return err
}
if err = f.Close(); err != nil {
return err
}
if err = os.Rename(f.Name(), b.historyPath()); err != nil {
return fmt.Errorf("save local history: %w", err)
}
return nil
}
// ReadHistory returns retained events independently of the delivery queue.
// Before the first new event after upgrade, the existing queue remains visible.
func (b *Buffer) ReadHistory() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
data, err := os.ReadFile(b.historyPath())
if os.IsNotExist(err) {
data, err = os.ReadFile(b.filePath)
}
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var events []*models.SystemEvent
if err := json.Unmarshal(data, &events); err != nil {
return nil, err
}
return events, nil
}
// GetBufferedEvents retrieves and clears the buffer atomically.
func (b *Buffer) GetBufferedEvents() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
var events []*models.SystemEvent
data, err := os.ReadFile(b.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read buffer file: %w", err)
}
if err := json.Unmarshal(data, &events); err != nil {
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
}
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
return nil, err
}
return events, nil
}
// ReadBufferedEvents retrieves buffered events without clearing them.
func (b *Buffer) ReadBufferedEvents() ([]*models.SystemEvent, error) {
b.mu.Lock()
defer b.mu.Unlock()
// Read buffer file
var events []*models.SystemEvent
data, err := os.ReadFile(b.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No buffer file means no events
}
return nil, fmt.Errorf("failed to read buffer file: %w", err)
}
if err := json.Unmarshal(data, &events); err != nil {
return nil, fmt.Errorf("failed to unmarshal events: %w", err)
}
return events, nil
}
// Clear removes the current buffer file.
func (b *Buffer) Clear() error {
b.mu.Lock()
defer b.mu.Unlock()
if err := os.Remove(b.filePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SetMaxSize sets the maximum number of events to buffer
func (b *Buffer) SetMaxSize(size int) {
b.mu.Lock()
defer b.mu.Unlock()
b.maxSize = size
}
// GetStats returns buffer statistics
func (b *Buffer) GetStats() (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
data, err := os.ReadFile(b.filePath)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
var events []*models.SystemEvent
if err := json.Unmarshal(data, &events); err != nil {
return 0, err
}
return len(events), nil
}

View file

@ -0,0 +1,318 @@
// Package event provides event helper functions and buffering for the RedFlag agent.
//
// ETHOS Compliance:
// (1) Errors are History, Not /dev/null
// - All failures are captured as events
// - Events include full context and metadata
// - Events are buffered for offline scenarios
//
// (3) Assume Failure; Build for Resilience
// - Best-effort event buffering (fails gracefully if buffer unavailable)
// - Events persist to disk for recovery
package event
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// ScannerEventType represents a scanner operation event
type ScannerEventType string
const (
// Event types for agent operations
EventTypeAgentScan = "agent_scan"
EventTypeAgentConfig = "agent_config"
EventTypeAgentCheckIn = "agent_checkin"
EventTypeAgentOffline = "agent_offline"
)
// ScanResult indicates the outcome of a scan operation
type ScanResult string
const (
ScanResultSuccess ScanResult = "success"
ScanResultFailed ScanResult = "failed"
ScanResultTimeout ScanResult = "timeout"
ScanResultSkipped ScanResult = "skipped"
)
// BufferSystemEvent records a SystemEvent into the operational event buffer.
// A nil buffer degrades to local-only behavior; callers should still log or
// report command results through their normal channel.
func BufferSystemEvent(buffer *Buffer, agentID uuid.UUID, eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
if buffer == nil {
return
}
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
evt := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: eventType,
EventSubtype: eventSubtype,
Severity: severity,
Component: component,
Message: message,
Metadata: metadata,
CreatedAt: time.Now().UTC(),
}
if err := buffer.BufferEvent(evt); err != nil {
log.Printf("[WARNING] [agent] [event] buffer_system_event_failed error=%v", err)
}
}
// NewScanFailureEvent creates a SystemEvent for a failed scan operation
//
// Parameters:
// - scannerName: The name of the scanner that failed (e.g., "apt", "docker")
// - err: The error that occurred
// - duration: How long the scan took before failing
// - agentID: The agent UUID (empty for client-side creation without binding)
//
// Returns a SystemEvent ready to buffer or send to the server.
func NewScanFailureEvent(scannerName string, err error, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
// Determine severity based on error type and scanner
severity := models.SeverityWarning
if isCriticalScanner(scannerName) {
severity = models.SeverityCritical
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentScan,
EventSubtype: models.SubtypeFailed,
Severity: severity,
Component: scannerName + "_scanner",
Message: fmt.Sprintf("%s scan failed after %v: %s", scannerName, duration.Round(time.Millisecond), err.Error()),
Metadata: map[string]interface{}{
"scanner": scannerName,
"error": err.Error(),
"duration_ms": duration.Milliseconds(),
"result": string(ScanResultFailed),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewScanSuccessEvent creates a SystemEvent for a successful scan operation
//
// Parameters:
// - scannerName: The name of the scanner
// - updateCount: Number of updates found
// - duration: How long the scan took
// - agentID: The agent UUID (empty for client-side creation without binding)
func NewScanSuccessEvent(scannerName string, updateCount int, duration time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
message := fmt.Sprintf("%s scan completed in %v", scannerName, duration.Round(time.Millisecond))
if updateCount > 0 {
message = fmt.Sprintf("%s scan found %d update(s) in %v", scannerName, updateCount, duration.Round(time.Millisecond))
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentScan,
EventSubtype: models.SubtypeSuccess,
Severity: models.SeverityInfo,
Component: scannerName + "_scanner",
Message: message,
Metadata: map[string]interface{}{
"scanner": scannerName,
"update_count": updateCount,
"duration_ms": duration.Milliseconds(),
"result": string(ScanResultSuccess),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewScanTimeoutEvent creates a SystemEvent for a scan that timed out
func NewScanTimeoutEvent(scannerName string, duration time.Duration, timeout time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentScan,
EventSubtype: models.SubtypeNetworkTimeout,
Severity: models.SeverityWarning,
Component: scannerName + "_scanner",
Message: fmt.Sprintf("%s scan timed out after %v (timeout: %v)", scannerName, duration.Round(time.Millisecond), timeout),
Metadata: map[string]interface{}{
"scanner": scannerName,
"duration_ms": duration.Milliseconds(),
"timeout_ms": timeout.Milliseconds(),
"result": string(ScanResultTimeout),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewConfigSyncEvent creates a SystemEvent for a config sync operation
func NewConfigSyncEvent(success bool, changes []string, attempt int, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
subtype := models.SubtypeSuccess
severity := models.SeverityInfo
message := fmt.Sprintf("Config synced successfully (attempt %d)", attempt)
if !success {
subtype = models.SubtypeFailed
severity = models.SeverityWarning
message = fmt.Sprintf("Config sync failed (attempt %d)", attempt)
} else if len(changes) > 0 {
message = fmt.Sprintf("Config updated: %v (attempt %d)", changes, attempt)
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentConfig,
EventSubtype: subtype,
Severity: severity,
Component: models.ComponentAgent,
Message: message,
Metadata: map[string]interface{}{
"success": success,
"changes": changes,
"attempt": attempt,
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewOfflineEvent creates a SystemEvent when the agent goes offline
func NewOfflineEvent(reason string, lastCheckIn time.Time, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
message := "Agent went offline"
if reason != "" {
message = fmt.Sprintf("Agent went offline: %s", reason)
}
var offlineDuration string
if !lastCheckIn.IsZero() {
offlineDuration = fmt.Sprintf("%v", time.Since(lastCheckIn).Round(time.Second))
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentOffline,
EventSubtype: models.SubtypeTokenExpired, // Reusing existing subtype
Severity: models.SeverityWarning,
Component: models.ComponentAgent,
Message: message,
Metadata: map[string]interface{}{
"reason": reason,
"last_check_in": lastCheckIn.Format(time.RFC3339),
"offline_duration": offlineDuration,
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewReconnectionEvent creates a SystemEvent when the agent reconnects
func NewReconnectionEvent(offlineDuration time.Duration, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentCheckIn,
EventSubtype: models.SubtypeSuccess,
Severity: models.SeverityInfo,
Component: models.ComponentAgent,
Message: fmt.Sprintf("Agent reconnected after %v offline", offlineDuration.Round(time.Second)),
Metadata: map[string]interface{}{
"offline_duration_ms": offlineDuration.Milliseconds(),
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// NewCheckInEvent creates a SystemEvent for a successful check-in
func NewCheckInEvent(hasCommands bool, commandCount int, agentID uuid.UUID) *models.SystemEvent {
var agentIDPtr *uuid.UUID
if agentID != uuid.Nil {
agentIDPtr = &agentID
}
subtype := models.SubtypeInfo
message := "Check-in successful - no new commands"
if hasCommands {
subtype = models.SubtypeSuccess
message = fmt.Sprintf("Check-in successful - received %d command(s)", commandCount)
}
return &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: EventTypeAgentCheckIn,
EventSubtype: subtype,
Severity: models.SeverityInfo,
Component: models.ComponentAgent,
Message: message,
Metadata: map[string]interface{}{
"has_commands": hasCommands,
"command_count": commandCount,
"timestamp": time.Now().UTC().Format(time.RFC3339),
},
CreatedAt: time.Now().UTC(),
}
}
// isCriticalScanner returns true if the scanner is considered critical
// Critical scanner failures are logged with SeverityCritical
func isCriticalScanner(scannerName string) bool {
// Update-related scanners that indicate potential security or update issues
criticalScanners := map[string]bool{
"apt": true, // Security patches
"dnf": true, // Security patches
"windows": true, // Security patches
"winget": false, // Optional updates
"docker": false, // Optional feature
"storage": false, // Monitoring only
"system": false, // Monitoring only
}
return criticalScanners[scannerName]
}

View file

@ -0,0 +1,56 @@
package event
import (
"os"
"path/filepath"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
func TestLocalHistorySurvivesDeliveryAndRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.json")
buffer := NewBuffer(path)
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "scan failed", Severity: "error"}
if err := buffer.BufferEvent(e); err != nil {
t.Fatal(err)
}
if err := buffer.Clear(); err != nil {
t.Fatal(err)
}
reopened := NewBuffer(path)
history, err := reopened.ReadHistory()
if err != nil || len(history) != 1 || history[0].ID != e.ID {
t.Fatalf("history=%v error=%v", history, err)
}
if err := reopened.BufferEvent(e); err != nil {
t.Fatal(err)
}
if _, err := reopened.GetBufferedEvents(); err != nil {
t.Fatal(err)
}
history, err = reopened.ReadHistory()
if err != nil || len(history) != 1 {
t.Fatalf("retried event duplicated in history: count=%d error=%v", len(history), err)
}
}
func TestHistoryFailureDoesNotDiscardDeliveryQueue(t *testing.T) {
buffer := NewBuffer(filepath.Join(t.TempDir(), "events.json"))
if err := os.WriteFile(buffer.historyPath(), []byte("damaged history"), 0600); err != nil {
t.Fatal(err)
}
e := &models.SystemEvent{ID: uuid.Must(uuid.NewV4()), Message: "keep this event"}
if err := buffer.BufferEvent(e); err == nil {
t.Fatal("history damage was silent")
}
pending, err := buffer.ReadBufferedEvents()
if err != nil || len(pending) != 1 {
t.Fatalf("delivery queue lost: count=%d error=%v", len(pending), err)
}
data, err := os.ReadFile(buffer.historyPath())
if err != nil || string(data) != "damaged history" {
t.Fatal("damaged history was overwritten")
}
}

View file

@ -0,0 +1,166 @@
// Package event provides event helper functions and buffering for the RedFlag agent.
//
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent in a
// single call. When the internal buffer is nil (e.g. during early boot or in
// tests), it degrades to log-only mode.
//
// ETHOS Compliance:
// (1) Errors are History, Not /dev/null
// - Every TeeLogger call produces a log line (stderr/journald)
// - Every TeeLogger call buffers a SystemEvent for server delivery
// - Buffer failures are logged, never silently dropped
//
// (3) Assume Failure; Build for Resilience
// - Nil buffer degrades gracefully to log-only
// - Buffer write is best-effort; log line is the durability guarantee
package event
import (
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// TeeLogger emits an ETHOS-tagged log.Printf AND a buffered SystemEvent
// in a single call. When the internal buffer is nil (e.g. during early boot
// or in test), it degrades to log-only mode.
type TeeLogger struct {
buffer *Buffer
agentID uuid.UUID
}
// NewTeeLogger creates a TeeLogger. Pass a nil buffer for log-only mode.
func NewTeeLogger(buffer *Buffer, agentID uuid.UUID) *TeeLogger {
return &TeeLogger{buffer: buffer, agentID: agentID}
}
// Buffer returns the operational event buffer used by this logger.
func (l *TeeLogger) Buffer() *Buffer {
if l == nil {
return nil
}
return l.buffer
}
// LogParams carries all structured fields for a dual-output event.
type LogParams struct {
Level string // ETHOS level tag: "INFO", "WARNING", "ERROR", "CRITICAL"
System string // ETHOS system tag: "agent"
Component string // ETHOS component tag: "migration", "crypto", "config"
EventType string // SystemEvent.EventType
EventSubtype string // SystemEvent.EventSubtype
Severity string // SystemEvent.Severity
ServerComponent string // SystemEvent.Component
Message string // Human-readable message
Metadata map[string]interface{} // Structured key-value pairs
}
// Log is the core dual-output method.
func (l *TeeLogger) Log(params LogParams) {
// Nil receiver: log-only mode (no buffer, no agent ID).
if l == nil {
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
return
}
// 1. ETHOS-tagged log line
log.Printf("[%s] [%s] [%s] %s", params.Level, params.System, params.Component, params.Message)
// 2. Buffer SystemEvent (best-effort, nil-safe)
if l.buffer == nil {
return
}
var agentIDPtr *uuid.UUID
if l.agentID != uuid.Nil {
agentIDPtr = &l.agentID
}
evt := &models.SystemEvent{
ID: uuid.Must(uuid.NewV4()),
AgentID: agentIDPtr,
EventType: params.EventType,
EventSubtype: params.EventSubtype,
Severity: params.Severity,
Component: params.ServerComponent,
Message: params.Message,
Metadata: params.Metadata,
CreatedAt: time.Now().UTC(),
}
if err := l.buffer.BufferEvent(evt); err != nil {
log.Printf("[WARNING] [agent] [event] tee_buffer_failed error=%v", err)
}
}
// Info emits an INFO-level dual-output event.
func (l *TeeLogger) Info(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "INFO", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeInfo,
Severity: models.SeverityInfo, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// Warning emits a WARNING-level dual-output event.
func (l *TeeLogger) Warning(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "WARNING", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeWarning,
Severity: models.SeverityWarning, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// Error emits an ERROR-level dual-output event.
func (l *TeeLogger) Error(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "ERROR", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeFailed,
Severity: models.SeverityError, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// Critical emits a CRITICAL-level dual-output event.
func (l *TeeLogger) Critical(system, component, serverComponent, message string, metadata map[string]interface{}) {
l.Log(LogParams{
Level: "CRITICAL", System: system, Component: component,
EventType: deriveEventType(component), EventSubtype: models.SubtypeCritical,
Severity: models.SeverityCritical, ServerComponent: serverComponent,
Message: message, Metadata: metadata,
})
}
// deriveEventType maps an ETHOS component tag to a SystemEvent.EventType.
// Callers can always override by using Log() directly with explicit params.
func deriveEventType(component string) string {
switch component {
case "migration":
return models.EventTypeAgentMigration
case "crypto":
return models.EventTypeAgentCrypto
case "config":
return models.EventTypeAgentConfig
case "docker":
return models.EventTypeAgentDocker
case "installer":
return models.EventTypeAgentInstall
case "kernel":
return models.EventTypeAgentStartup
case "acknowledgment":
return models.EventTypeAgentCheckIn
case "receipt":
return models.EventTypeAgentCheckIn
case "localapi":
return models.EventTypeAgentStartup
case "loop":
return models.EventTypeAgentStartup
default:
return models.EventTypeError
}
}

View file

@ -0,0 +1,169 @@
package event
import (
"testing"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
func TestTeeLoggerNilBuffer(t *testing.T) {
// TeeLogger with nil buffer should not panic
logger := NewTeeLogger(nil, uuid.Nil)
// Should not panic — log-only mode
logger.Info("agent", "test", "test_component", "test message", nil)
logger.Warning("agent", "test", "test_component", "test warning", nil)
logger.Error("agent", "test", "test_component", "test error", nil)
logger.Critical("agent", "test", "test_component", "test critical", nil)
}
func TestTeeLoggerNilAgentID(t *testing.T) {
// TeeLogger with Nil agent ID should produce events with nil AgentID
buffer := NewBuffer(t.TempDir() + "/events.json")
logger := NewTeeLogger(buffer, uuid.Nil)
logger.Info("agent", "test", "test_component", "test message", nil)
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].AgentID != nil {
t.Errorf("expected nil AgentID, got %v", events[0].AgentID)
}
}
func TestTeeLoggerEventFields(t *testing.T) {
buffer := NewBuffer(t.TempDir() + "/events.json")
agentID := uuid.Must(uuid.NewV4())
logger := NewTeeLogger(buffer, agentID)
metadata := map[string]interface{}{
"key1": "value1",
"key2": 42,
}
logger.Warning("agent", "crypto", "pubkey", "key fetch failed", metadata)
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
evt := events[0]
if evt.AgentID == nil || *evt.AgentID != agentID {
t.Errorf("expected AgentID %v, got %v", agentID, evt.AgentID)
}
if evt.EventType != models.EventTypeAgentCrypto {
t.Errorf("expected EventType %q, got %q", models.EventTypeAgentCrypto, evt.EventType)
}
if evt.EventSubtype != models.SubtypeWarning {
t.Errorf("expected EventSubtype %q, got %q", models.SubtypeWarning, evt.EventSubtype)
}
if evt.Severity != models.SeverityWarning {
t.Errorf("expected Severity %q, got %q", models.SeverityWarning, evt.Severity)
}
if evt.Component != "pubkey" {
t.Errorf("expected Component %q, got %q", "pubkey", evt.Component)
}
if evt.Message != "key fetch failed" {
t.Errorf("expected Message %q, got %q", "key fetch failed", evt.Message)
}
if evt.Metadata["key1"] != "value1" {
t.Errorf("expected metadata key1=value1, got %v", evt.Metadata["key1"])
}
// JSON round-trip converts int to float64
if int(evt.Metadata["key2"].(float64)) != 42 {
t.Errorf("expected metadata key2=42, got %v", evt.Metadata["key2"])
}
}
func TestTeeLoggerConvenienceMethods(t *testing.T) {
buffer := NewBuffer(t.TempDir() + "/events.json")
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
tests := []struct {
name string
call func()
expectedLevel string
expectedSubtype string
expectedSeverity string
}{
{"Info", func() { logger.Info("agent", "config", "config", "loaded", nil) }, "INFO", models.SubtypeInfo, models.SeverityInfo},
{"Warning", func() { logger.Warning("agent", "config", "config", "stale", nil) }, "WARNING", models.SubtypeWarning, models.SeverityWarning},
{"Error", func() { logger.Error("agent", "config", "config", "failed", nil) }, "ERROR", models.SubtypeFailed, models.SeverityError},
{"Critical", func() { logger.Critical("agent", "config", "config", "corrupt", nil) }, "CRITICAL", models.SubtypeCritical, models.SeverityCritical},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.call()
})
}
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != len(tests) {
t.Fatalf("expected %d events, got %d", len(tests), len(events))
}
for i, tt := range tests {
evt := events[i]
if evt.EventSubtype != tt.expectedSubtype {
t.Errorf("%s: expected EventSubtype %q, got %q", tt.name, tt.expectedSubtype, evt.EventSubtype)
}
if evt.Severity != tt.expectedSeverity {
t.Errorf("%s: expected Severity %q, got %q", tt.name, tt.expectedSeverity, evt.Severity)
}
}
}
func TestDeriveEventType(t *testing.T) {
tests := []struct {
component string
expected string
}{
{"migration", models.EventTypeAgentMigration},
{"crypto", models.EventTypeAgentCrypto},
{"config", models.EventTypeAgentConfig},
{"docker", models.EventTypeAgentDocker},
{"installer", models.EventTypeAgentInstall},
{"unknown", models.EventTypeError},
}
for _, tt := range tests {
t.Run(tt.component, func(t *testing.T) {
got := deriveEventType(tt.component)
if got != tt.expected {
t.Errorf("deriveEventType(%q) = %q, want %q", tt.component, got, tt.expected)
}
})
}
}
func TestTeeLoggerMetadataNil(t *testing.T) {
buffer := NewBuffer(t.TempDir() + "/events.json")
logger := NewTeeLogger(buffer, uuid.Must(uuid.NewV4()))
// Should not panic with nil metadata
logger.Info("agent", "test", "test_component", "test message", nil)
events, err := buffer.GetBufferedEvents()
if err != nil {
t.Fatalf("GetBufferedEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
if events[0].Metadata != nil {
t.Errorf("expected nil metadata, got %v", events[0].Metadata)
}
}

View file

@ -0,0 +1,63 @@
package guardian
import (
"fmt"
"sync"
)
// IntervalGuardian protects against accidental check-in interval overrides
type IntervalGuardian struct {
mu sync.Mutex
lastCheckInValue int
violationCount int
}
// NewIntervalGuardian creates a new guardian with zero violations
func NewIntervalGuardian() *IntervalGuardian {
return &IntervalGuardian{
lastCheckInValue: 0,
violationCount: 0,
}
}
// SetBaseline records the expected check-in interval
func (g *IntervalGuardian) SetBaseline(interval int) {
g.mu.Lock()
defer g.mu.Unlock()
g.lastCheckInValue = interval
}
// CheckForOverrideAttempt validates that proposed interval matches baseline
// Returns error if mismatch detected (indicating a regression)
func (g *IntervalGuardian) CheckForOverrideAttempt(currentBaseline, proposedValue int) error {
g.mu.Lock()
defer g.mu.Unlock()
if currentBaseline != proposedValue {
g.violationCount++
return fmt.Errorf("INTERVAL_OVERRIDE_DETECTED: baseline=%d, proposed=%d, violations=%d",
currentBaseline, proposedValue, g.violationCount)
}
return nil
}
// GetViolationCount returns total number of violations detected
func (g *IntervalGuardian) GetViolationCount() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.violationCount
}
// Reset clears violation count (use after legitimate config change)
func (g *IntervalGuardian) Reset() {
g.mu.Lock()
defer g.mu.Unlock()
g.violationCount = 0
}
// GetBaseline returns current baseline value
func (g *IntervalGuardian) GetBaseline() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.lastCheckInValue
}

View file

@ -0,0 +1,13 @@
//go:build !windows
package handlers
import "fmt"
// dispatchWindowsRestart is a non-Windows stub so the package compiles on every
// platform. restartAgentService only invokes it under runtime.GOOS == "windows";
// on Linux the privileged helper owns the restart, and macOS has no self-restart
// path yet (its binaries aren't signed).
func dispatchWindowsRestart(service string) error {
return fmt.Errorf("windows restart path invoked on non-windows platform")
}

View file

@ -0,0 +1,35 @@
//go:build windows
package handlers
import (
"fmt"
"log"
"os/exec"
"syscall"
)
// dispatchWindowsRestart cycles the agent's own Windows service after the binary
// has already been swapped on disk.
//
// A service cannot stop itself and then start again from the same process: the
// stop terminates this process before the start runs, so nothing issues the start.
// The work is handed to a detached child that outlives us — it waits a few seconds
// for this process to exit, then stops and starts the service. This is the standard
// self-update restart pattern for a Windows service, the same shape any tray/agent
// app (Avira, AGV, etc.) uses to relaunch itself after replacing its own binary.
func dispatchWindowsRestart(service string) error {
// `ping -n 4 localhost` is a dependency-free ~3s sleep; then stop and start.
script := fmt.Sprintf("ping -n 4 127.0.0.1 >nul & sc stop %s & sc start %s", service, service)
cmd := exec.Command("cmd", "/C", script)
cmd.SysProcAttr = &syscall.SysProcAttr{
// DETACHED_PROCESS (0x8) | CREATE_NEW_PROCESS_GROUP (0x200): fully decouple
// the child so it survives this process being killed by the service stop.
CreationFlags: 0x00000008 | 0x00000200,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to launch detached restart helper: %w", err)
}
log.Printf("[INFO] [agent] [service] windows_restart_dispatched service=%s", service)
return nil
}

View file

@ -0,0 +1,557 @@
package handlers
import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"os"
"runtime"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
)
// HandleUpdateAgent handles agent update commands with signature verification
func HandleUpdateAgent(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
version, ok := params["version"].(string)
if !ok {
return fmt.Errorf("missing version parameter")
}
platform, ok := params["platform"].(string)
if !ok {
return fmt.Errorf("missing platform parameter")
}
downloadURL, ok := params["download_url"].(string)
if !ok {
return fmt.Errorf("missing download_url parameter")
}
signature, ok := params["signature"].(string)
if !ok {
return fmt.Errorf("missing signature parameter")
}
checksum, ok := params["checksum"].(string)
if !ok {
return fmt.Errorf("missing checksum parameter")
}
nonceUUIDStr, ok := params["nonce_uuid"].(string)
if !ok {
return fmt.Errorf("missing nonce_uuid parameter")
}
nonceTimestampStr, ok := params["nonce_timestamp"].(string)
if !ok {
return fmt.Errorf("missing nonce_timestamp parameter")
}
nonceSignature, ok := params["nonce_signature"].(string)
if !ok {
return fmt.Errorf("missing nonce_signature parameter")
}
log.Printf("[INFO] [agent] [update] starting version=%s platform=%s", version, platform)
// Nonce max-age = 2 × check-in interval so the nonce survives the gap between
// being queued server-side and being fetched at the agent's next poll cycle.
nonceMaxAge := time.Duration(cfg.CheckInInterval*2) * time.Second
log.Printf("[tunturi_ed25519] Validating nonce...")
if err := validateNonce(nonceUUIDStr, nonceTimestampStr, nonceSignature, nonceMaxAge); err != nil {
log.Printf("[ERROR] [agent] [security] nonce_validation_failed error=%v", err)
return fmt.Errorf("[tunturi_ed25519] nonce validation failed: %w", err)
}
log.Printf("[INFO] [agent] [security] nonce_validated")
updateStartTime := time.Now()
logReport := client.LogReport{
CommandID: commandID,
Action: "update_agent",
Result: "started",
Stdout: fmt.Sprintf("Starting agent update to version %s\n", version),
Stderr: "",
ExitCode: 0,
DurationSeconds: 0,
Metadata: map[string]string{
"subsystem_label": "Agent Update",
"subsystem": "agent",
"target_version": version,
},
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [update] report_start_failed error=%v", err)
}
log.Printf("[INFO] [agent] [update] download_start url=%s", downloadURL)
// The /api/v1/downloads/updates/:package_id route is protected by AuthMiddleware
// — the prior implementation used a raw http.Client.Get and would 401 in
// production. Use the authenticated client so JWT + X-Machine-ID accompany the
// request. Relative-URL resolution happens inside DownloadAuthenticatedToFile.
tempBinaryPath, err := downloadUpdatePackage(apiClient, downloadURL)
if err != nil {
return fmt.Errorf("failed to download update package: %w", err)
}
defer os.Remove(tempBinaryPath)
log.Printf("[INFO] [agent] [update] download_complete")
actualChecksum, err := computeSHA256(tempBinaryPath)
if err != nil {
return fmt.Errorf("failed to compute checksum: %w", err)
}
if actualChecksum != checksum {
return fmt.Errorf("checksum mismatch: expected %s, got %s", checksum, actualChecksum)
}
log.Printf("[INFO] [agent] [upgrade] checksum_verified checksum=%s", actualChecksum)
log.Printf("[tunturi_ed25519] Step 3: Verifying Ed25519 signature...")
if err := verifyBinarySignature(tempBinaryPath, signature); err != nil {
return fmt.Errorf("[tunturi_ed25519] signature verification failed: %w", err)
}
log.Printf("[INFO] [agent] [upgrade] ed25519_signature_verified")
// Linux: the privileged binary swap is performed by the root helper, not the
// agent's own sudo. The server signed an agent-self capability token over this
// binary's hash; the helper re-verifies and installs. Other platforms keep the
// legacy direct path below until a platform executor exists.
if runtime.GOOS == "linux" {
// Download the helper binary if the server provided a URL.
var helperBinaryPath string
if helperURL, ok := params["helper_download_url"].(string); ok && helperURL != "" {
var dlErr error
helperBinaryPath, dlErr = downloadUpdatePackage(apiClient, helperURL)
if dlErr != nil {
log.Printf("[WARNING] [agent] [upgrade] helper_download_failed error=%v — agent will update, helper stays as-is", dlErr)
} else {
defer os.Remove(helperBinaryPath)
// Verify helper checksum if provided
if helperChecksum, ok := params["helper_checksum"].(string); ok && helperChecksum != "" {
actual, err := computeSHA256(helperBinaryPath)
if err != nil {
log.Printf("[WARNING] [agent] [upgrade] helper_checksum_compute_failed error=%v", err)
helperBinaryPath = ""
} else if actual != helperChecksum {
log.Printf("[WARNING] [agent] [upgrade] helper_checksum_mismatch expected=%s got=%s", helperChecksum, actual)
helperBinaryPath = ""
}
}
}
}
return installAgentViaHelper(tempBinaryPath, helperBinaryPath, params, commandID, version, updateStartTime)
}
currentBinaryPath, err := getCurrentBinaryPath()
if err != nil {
return fmt.Errorf("failed to determine current binary path: %w", err)
}
backupPath := currentBinaryPath + ".bak"
var updateSuccess bool = false
if err := createBackup(currentBinaryPath, backupPath); err != nil {
log.Printf("[ERROR] [agent] [upgrade] backup_failed error=%v", err)
} else {
defer func() {
if updateSuccess {
// Binary swap and restart dispatch succeeded. systemd's SIGTERM
// is imminent and may abort this defer before completion. .bak
// stays on disk: the new binary cleans it up after first
// successful check-in attestation (cleanupPostUpdateBackup),
// or the operator restores from it if the new binary fails to
// start. Completion is owned by server-side
// timeout.reconcileAgentUpdates.
return
}
log.Printf("[INFO] [agent] [upgrade] rollback_start reason=pre_restart_error")
if restoreErr := restoreFromBackup(backupPath, currentBinaryPath); restoreErr != nil {
log.Printf("[ERROR] [agent] [upgrade] rollback_failed error=%v", restoreErr)
} else {
log.Printf("[INFO] [agent] [upgrade] rollback_success")
}
}()
}
log.Printf("[INFO] [agent] [upgrade] install_start")
if err := installNewBinary(tempBinaryPath, currentBinaryPath); err != nil {
return fmt.Errorf("failed to install new binary: %w", err)
}
// Past the point of no return on disk. The watchdog-and-final-log-report
// pattern used to live here; it could not survive systemd's SIGTERM and
// has been removed. Server-side reconcileAgentUpdates closes the command
// when the new binary reports its version on next check-in.
updateSuccess = true
log.Printf("[INFO] [agent] [upgrade] install_complete duration_seconds=%d", int(time.Since(updateStartTime).Seconds()))
// Binary is committed on disk — even if the restart dispatch below fails, the
// next boot runs the new binary, so the marker stays valid either way.
if err := WriteUpgradeAttestation(commandID, version); err != nil {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
}
log.Printf("[INFO] [agent] [upgrade] restart_dispatch")
if err := restartAgentService(); err != nil {
// Binary is swapped on disk, but restart failed. The current process
// keeps running on the OLD code; .bak remains for manual restore on
// next manual boot.
return fmt.Errorf("failed to restart agent: %w", err)
}
return nil
}
// installAgentViaHelper performs the Linux agent self-upgrade through the
// privileged helper. The agent stages the verified binary on the real filesystem
// (the helper's transient-unit mount namespace cannot see the agent's PrivateTmp)
// and hands the helper the server-signed agent-self token. The helper verifies the
// token signature and the staged binary's hash, backs up, installs, chmods, and
// restarts the agent — the agent holds no sudo for cp/chmod/systemctl.
func installAgentViaHelper(tempBinaryPath, helperBinaryPath string, params map[string]interface{}, commandID, targetVersion string, startTime time.Time) error {
// Must match the helper's DEFAULT_AGENT_UPGRADE_SOURCE.
upgradeStagingPath := constants.GetAgentStagingPath("pending-upgrade.bin")
if err := copyFile(tempBinaryPath, upgradeStagingPath); err != nil {
return fmt.Errorf("failed to stage new binary for helper: %w", err)
}
// Stage the helper binary if we downloaded one. The helper will
// self-update from this staged copy before installing the new agent.
helperStagingPath := constants.GetAgentStagingPath("pending-helper.bin")
hasHelper := false
if helperBinaryPath != "" {
if err := copyFile(helperBinaryPath, helperStagingPath); err != nil {
log.Printf("[WARNING] [agent] [upgrade] helper_stage_failed error=%v — agent will update, helper stays as-is", err)
} else {
hasHelper = true
}
}
// Clean up staged binaries on failure. On success the helper restarts
// us (SIGTERM) before we reach this defer, so the files are already
// replaced and this is a harmless no-op. The attestation marker is also
// dropped on failure — no restart happened, so the next boot must not
// attest against this command.
success := false
defer func() {
if !success {
for _, p := range []string{upgradeStagingPath, helperStagingPath} {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
log.Printf("[WARNING] [agent] [upgrade] staging_cleanup_failed path=%s error=%v", p, err)
}
}
ClearUpgradeAttestation()
}
}()
tokenJSON, ok := params["capability_token"].(string)
if !ok || tokenJSON == "" {
return fmt.Errorf("missing capability_token — server did not authorize the helper swap")
}
var token capability.Token
if err := json.Unmarshal([]byte(tokenJSON), &token); err != nil {
return fmt.Errorf("failed to parse capability_token: %w", err)
}
// The marker must be on disk before the helper runs: on success the helper
// restarts this process mid-Execute, and the post-upgrade healthcheck in the
// new binary (RunUpgradeAttestation) is what verifies the swap actually took.
if err := WriteUpgradeAttestation(commandID, targetVersion); err != nil {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_write_failed error=%v — upgrade proceeds unattested", err)
}
log.Printf("[INFO] [agent] [upgrade] invoking_helper token_id=%s has_helper=%v", token.TokenID, hasHelper)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Build args for the helper. --helper-file tells the helper where the
// staged helper binary lives so it can self-update before installing
// the new agent.
var helperArgs []string
if hasHelper {
helperArgs = []string{"--helper-file", helperStagingPath}
}
result, err := supplychain.NewExecutor("").Execute(ctx, &token, helperArgs...)
if err != nil {
// On success the helper restarts this agent, which can SIGTERM us before
// Execute returns. The new binary reporting its version on next check-in is
// the authoritative success signal (server reconcileAgentUpdates closes the
// command), so a transport error here is not necessarily a failed upgrade.
return fmt.Errorf("helper invocation failed (or agent restarted mid-swap): %w", err)
}
if result.Decision != "executed" {
return fmt.Errorf("helper refused agent upgrade: decision=%s reason=%s exit=%d",
result.Decision, result.Reason, result.ExitCode)
}
success = true
log.Printf("[INFO] [agent] [upgrade] helper_swap_complete token_id=%s duration_seconds=%d",
token.TokenID, int(time.Since(startTime).Seconds()))
return nil
}
// copyFile copies src to dst (truncating dst). No privilege required — used to
// place the verified binary on the real filesystem where the root helper can read
// it (the agent's own /var/lib/redflag/agent is writable and outside PrivateTmp).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// --- Helper functions ---
// downloadUpdatePackage streams the new agent binary into a temp file using the
// agent's authenticated client. Caller owns the returned path (use defer os.Remove).
// The 500MB cap mirrors the prior implementation; an oversize response returns an
// error rather than a silently-truncated binary that would fail signature verify.
func downloadUpdatePackage(apiClient *client.Client, downloadURL string) (string, error) {
tempFile, err := os.CreateTemp("", "redflag-update-*.bin")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
tempPath := tempFile.Name()
tempFile.Close() // DownloadAuthenticatedToFile re-opens via os.Create
const maxBinarySize = 500 * 1024 * 1024
if _, err := apiClient.DownloadAuthenticatedToFile(downloadURL, tempPath, maxBinarySize); err != nil {
os.Remove(tempPath)
return "", fmt.Errorf("failed to download: %w", err)
}
return tempPath, nil
}
func computeSHA256(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", fmt.Errorf("failed to compute hash: %w", err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func getCurrentBinaryPath() (string, error) {
execPath, err := os.Executable()
if err != nil {
return "", fmt.Errorf("failed to get executable path: %w", err)
}
return execPath, nil
}
// No post-update backup sweep exists by design. On Linux the helper owns the
// binary swap and writes <binary>.bak as root into the root-owned install dir
// (helper/src/main.rs install_staged_agent_binary). The agent runs unprivileged
// and its only sudo grant is the systemd-run helper invocation — it cannot, and
// must not, remove that file directly. Each upgrade's fs::copy truncates .bak in
// place, so it is always a single slot holding exactly the version before the one
// now running: the correct rollback target, maintained without the agent touching
// it. Discarding it after first check-in (the prior behavior) only threw away
// rollback while the new binary was still unproven.
// createBackup copies the current binary to dst as the rollback slot. Pure file
// I/O, no privilege escalation: this runs only on non-Linux platforms (Windows,
// macOS), where the agent process owns its own install directory. Linux never
// reaches here — the privileged helper owns the swap and writes .bak itself.
func createBackup(src, dst string) error {
if err := copyFile(src, dst); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
if err := os.Chmod(dst, 0o755); err != nil {
return fmt.Errorf("failed to set backup permissions: %w", err)
}
return nil
}
func restoreFromBackup(backup, target string) error {
// The live target may be a running, locked binary. On Windows it can't be
// overwritten in place but can be renamed out of the way first; on POSIX it
// can be unlinked while the old image keeps running off its open inode.
if runtime.GOOS == "windows" {
aside := target + ".old"
_ = os.Remove(aside)
_ = os.Rename(target, aside)
} else if _, err := os.Stat(target); err == nil {
if err := os.Remove(target); err != nil {
return fmt.Errorf("failed to remove current binary: %w", err)
}
}
return createBackup(backup, target)
}
// installNewBinary stages the verified binary beside the current one and swaps it
// into place with pure file I/O — no sudo, no helper. Reached only on non-Linux
// platforms (Linux swaps through the helper). The agent service account has write
// access to its own install directory on both Windows and macOS.
func installNewBinary(src, dst string) error {
staged := dst + ".new"
if err := copyFile(src, staged); err != nil {
return fmt.Errorf("failed to stage new binary: %w", err)
}
if err := os.Chmod(staged, 0o755); err != nil {
os.Remove(staged)
return fmt.Errorf("failed to set binary permissions: %w", err)
}
if runtime.GOOS == "windows" {
// A running .exe can't be overwritten, but Windows permits renaming it.
// Move the live binary aside, then move the staged binary into the real
// path. The aside copy is locked until this process exits; it self-clears
// on the next upgrade (the os.Remove above) — a single stale slot at rest.
aside := dst + ".old"
_ = os.Remove(aside) // rename won't clobber an existing target on Windows
if err := os.Rename(dst, aside); err != nil {
os.Remove(staged)
return fmt.Errorf("failed to move running binary aside: %w", err)
}
if err := os.Rename(staged, dst); err != nil {
os.Rename(aside, dst) // undo: restore the original
return fmt.Errorf("failed to install new binary: %w", err)
}
return nil
}
// POSIX: atomically replace the directory entry. The running process keeps its
// already-open image until it restarts.
if err := os.Rename(staged, dst); err != nil {
os.Remove(staged)
return fmt.Errorf("failed to install new binary: %w", err)
}
return nil
}
// restartAgentService cycles the agent's own service after the on-disk binary swap.
// Only reached on non-Linux platforms: Linux self-upgrades run end to end through
// the privileged helper (installAgentViaHelper), which restarts the unit itself.
func restartAgentService() error {
switch runtime.GOOS {
case "windows":
return dispatchWindowsRestart("RedFlagAgent")
default:
return fmt.Errorf("self-restart not supported on %s; restart the service manually", runtime.GOOS)
}
}
// --- Signature verification ---
func verifyBinarySignature(binaryPath, signatureHex string) error {
publicKey, err := getServerPublicKey()
if err != nil {
return fmt.Errorf("failed to get server public key: %w", err)
}
content, err := os.ReadFile(binaryPath)
if err != nil {
return fmt.Errorf("failed to read binary: %w", err)
}
signatureBytes, err := hex.DecodeString(signatureHex)
if err != nil {
return fmt.Errorf("failed to decode signature: %w", err)
}
if len(signatureBytes) != ed25519.SignatureSize {
return fmt.Errorf("invalid signature length: expected %d bytes, got %d", ed25519.SignatureSize, len(signatureBytes))
}
valid := ed25519.Verify(ed25519.PublicKey(publicKey), content, signatureBytes)
if !valid {
return fmt.Errorf("signature verification failed: invalid signature")
}
return nil
}
func getServerPublicKey() ([]byte, error) {
publicKey, err := loadCachedPublicKeyDirect()
if err != nil {
return nil, fmt.Errorf("failed to load server public key: %w (hint: key is fetched at agent startup)", err)
}
return publicKey, nil
}
func loadCachedPublicKeyDirect() ([]byte, error) {
keyPath := constants.GetServerPublicKeyPath()
data, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("public key not found at %s: %w", keyPath, err)
}
if len(data) != 32 {
return nil, fmt.Errorf("invalid public key size: expected 32 bytes, got %d", len(data))
}
return data, nil
}
func validateNonce(nonceUUIDStr, nonceTimestampStr, nonceSignature string, maxAge time.Duration) error {
nonceTimestamp, err := time.Parse(time.RFC3339, nonceTimestampStr)
if err != nil {
return fmt.Errorf("invalid nonce timestamp format: %w", err)
}
age := time.Since(nonceTimestamp)
if age > maxAge {
return fmt.Errorf("nonce expired: age %v > %v", age, maxAge)
}
if age < 0 {
return fmt.Errorf("nonce timestamp in the future: %v", nonceTimestamp)
}
publicKey, err := getServerPublicKey()
if err != nil {
return fmt.Errorf("failed to get server public key: %w", err)
}
nonceData := fmt.Sprintf("%s:%d", nonceUUIDStr, nonceTimestamp.Unix())
signatureBytes, err := hex.DecodeString(nonceSignature)
if err != nil {
return fmt.Errorf("invalid nonce signature format: %w", err)
}
if len(signatureBytes) != ed25519.SignatureSize {
return fmt.Errorf("invalid nonce signature length: expected %d bytes, got %d",
ed25519.SignatureSize, len(signatureBytes))
}
valid := ed25519.Verify(ed25519.PublicKey(publicKey), []byte(nonceData), signatureBytes)
if !valid {
return fmt.Errorf("invalid nonce signature")
}
return nil
}

View file

@ -0,0 +1,241 @@
package handlers
import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"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"
)
// 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
ackTracker.Add(logReport.CommandID)
// Save acknowledgment state immediately
if err := ackTracker.Save(); err != nil {
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
}
// Report the log to the server
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
// If reporting failed, increment retry count but don't remove from pending
ackTracker.IncrementRetry(logReport.CommandID)
return err
}
return nil
}
// ScanCommand performs a local scan and displays results
func ScanCommand(cfg *config.Config, exportFormat string) error {
// Initialize scanners
aptScanner := scanner.NewAPTScanner()
dnfScanner := scanner.NewDNFScanner()
dockerScanner, _ := orchestrator.NewDockerScanner()
windowsUpdateScanner := scanner.NewWindowsUpdateScanner()
wingetScanner := scanner.NewWingetScanner()
fmt.Println("Scanning for updates...")
var allUpdates []client.UpdateReportItem
var scanResults []orchestrator.ScanResult
// Scan APT updates
if aptScanner.IsAvailable() {
fmt.Println(" - Scanning APT packages...")
result := runLocalScanner("apt", aptScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" APT scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d APT updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan DNF updates
if dnfScanner.IsAvailable() {
fmt.Println(" - Scanning DNF packages...")
result := runLocalScanner("dnf", dnfScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" DNF scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d DNF updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan Docker updates
if dockerScanner != nil && dockerScanner.IsAvailable() {
fmt.Println(" - Scanning Docker images...")
result := runLocalScanner("docker", dockerScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" Docker scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d Docker image updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan Windows updates
if windowsUpdateScanner.IsAvailable() {
fmt.Println(" - Scanning Windows updates...")
result := runLocalScanner("windows", windowsUpdateScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" Windows Update scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d Windows updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
// Scan Winget packages
if wingetScanner.IsAvailable() {
fmt.Println(" - Scanning Winget packages...")
result := runLocalScanner("winget", wingetScanner.Scan)
scanResults = append(scanResults, result)
if result.Error != nil {
fmt.Printf(" Winget scan failed: %v\n", result.Error)
} else {
fmt.Printf(" ✓ Found %d Winget package updates\n", len(result.Updates))
allUpdates = append(allUpdates, result.Updates...)
}
}
recordLocalFullScan(cfg, allUpdates, scanResults)
// Display results
fmt.Println()
return display.PrintScanResults(allUpdates, exportFormat)
}
func runLocalScanner(scannerName string, scan func() ([]client.UpdateReportItem, error)) orchestrator.ScanResult {
startTime := time.Now()
updates, err := scan()
result := orchestrator.ScanResult{
ScannerName: scannerName,
Updates: updates,
Error: err,
Duration: time.Since(startTime),
Status: "success",
}
if err != nil {
result.Status = "failed"
}
return result
}
// StatusCommand displays agent status information
func StatusCommand(cfg *config.Config) error {
fmt.Println("==================================================================")
fmt.Println("🚩 RedFlag Agent Status")
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)
// Registration status
if cfg.IsRegistered() {
fmt.Println("Registration: Registered")
} else {
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)
}
// 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.Println("==================================================================")
return nil
}
// ListUpdatesCommand lists detailed update information
func ListUpdatesCommand(cfg *config.Config, exportFormat string) error {
// Load cache to get last scan results
localCache, err := cache.Load()
if err != nil {
return fmt.Errorf("failed to load cache: %w", err)
}
if localCache.LastScanTime.IsZero() {
fmt.Println("No scan results available. Run -scan first.")
return nil
}
updates := localCache.Updates
if len(updates) == 0 {
fmt.Println("No updates available. System is up to date!")
return nil
}
// Handle export formats
switch exportFormat {
case "json":
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(updates)
case "csv":
writer := csv.NewWriter(os.Stdout)
// Write header
writer.Write([]string{"Name", "Current Version", "Available Version", "Source", "Severity"})
// Write data
for _, u := range updates {
writer.Write([]string{u.PackageName, u.CurrentVersion, u.AvailableVersion, u.RepositorySource, u.Severity})
}
writer.Flush()
return writer.Error()
default:
// Table format
fmt.Printf("Found %d updates:\n\n", len(updates))
fmt.Printf("%-30s %-20s %-20s %-10s\n", "NAME", "CURRENT", "AVAILABLE", "SOURCE")
fmt.Println(string(make([]byte, 80)))
for _, u := range updates {
fmt.Printf("%-30s %-20s %-20s %-10s\n",
truncate(u.PackageName, 30),
truncate(u.CurrentVersion, 20),
truncate(u.AvailableVersion, 20),
u.RepositorySource)
}
}
return nil
}
// truncate truncates a string to max length with ellipsis
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}

View file

@ -0,0 +1,90 @@
package handlers
import (
"fmt"
"log"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
"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
// platform-specific command types. Errors from the underlying handler are
// logged here so both the regular agent loop and the Windows service path
// produce a uniform error trail.
func DispatchCrossPlatformCommand(
apiClient *client.Client,
cfg *config.Config,
ackTracker *acknowledgment.Tracker,
orch *orchestrator.Orchestrator,
eventBuffer *event.Buffer,
cmd client.Command,
) bool {
var err error
switch cmd.Type {
case "scan_storage":
err = HandleScanStorage(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_system":
err = HandleScanSystem(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_docker":
err = HandleScanDocker(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_apt":
err = HandleScanAPT(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_dnf":
err = HandleScanDNF(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_windows":
err = HandleScanWindows(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_winget":
err = HandleScanWinget(apiClient, cfg, ackTracker, orch, cmd.ID)
case "scan_updates":
err = HandleScanUpdates(apiClient, cfg, ackTracker, orch, cmd.ID)
case "install_updates":
err = HandleInstallUpdates(apiClient, cfg, ackTracker, eventBuffer, cmd.Params, cmd.ID)
case "dry_run_update":
err = HandleDryRunUpdate(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "confirm_dependencies":
err = HandleConfirmDependencies(apiClient, cfg, ackTracker, eventBuffer, cmd.Params, cmd.ID)
case "enable_heartbeat":
err = HandleEnableHeartbeat(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "disable_heartbeat":
err = HandleDisableHeartbeat(apiClient, cfg, ackTracker, cmd.ID)
case "update_agent":
err = HandleUpdateAgent(apiClient, cfg, ackTracker, cmd.Params, cmd.ID)
case "reboot":
err = HandleReboot(apiClient, cfg, ackTracker, cmd.ID, cmd.Params)
case "capture_screenshot":
err = HandleCaptureScreenshot(apiClient, cfg, ackTracker, cmd.ID)
case "scan_processes":
err = HandleScanProcesses(apiClient, cfg, ackTracker, cmd.ID)
default:
return false
}
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

@ -0,0 +1,289 @@
package handlers
import (
"fmt"
"log"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/models"
)
// HandleDryRunUpdate runs a package-manager dry-run for the named update so
// dependency resolution can be surfaced to the dashboard operator before the
// real install fires. Reports dependencies via the dedicated endpoint and the
// command result via the standard log path; the install itself does not happen
// here — the operator's "Confirm" produces a separate confirm_dependencies
// command (see HandleConfirmDependencies).
func HandleDryRunUpdate(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
updateID, _ := params["update_id"].(string)
availableVersion, _ := params["available_version"].(string)
targetVersion, _ := params["target_version"].(string)
if targetVersion == "" {
targetVersion = availableVersion
}
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
}
if !inst.IsAvailable() {
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
}
log.Printf("[INFO] [agent] [installer] dry_run_start package=%s version=%s type=%s", packageName, targetVersion, packageType)
result, err := inst.DryRun(packageName, targetVersion)
if err != nil {
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
if result != nil {
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
}
logReport := client.LogReport{
CommandID: commandID,
Action: "dry_run",
Result: "failed",
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: duration,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("dry run failed: %w", err)
}
// Resolve the canonical artifact hashes for the closure (top-level package +
// dependencies) from the agent's signed repo metadata. This is the registry's
// hash source for OS package managers — the server cannot reach the agent's
// repos. Best-effort per package: a resolution failure is logged and that
// entry is omitted, never guessed. The server mints only over what resolved.
closure := resolveClosureHashes(packageType, packageName, targetVersion, result.Dependencies)
// Mirror the result into the client's wire type and post dependencies.
depReport := client.DependencyReport{
PackageName: packageName,
PackageType: packageType,
TargetVersion: targetVersion,
Dependencies: result.Dependencies,
UpdateID: updateID,
DryRunResult: &client.InstallResult{
Success: result.Success,
ErrorMessage: result.ErrorMessage,
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Action: result.Action,
PackagesInstalled: result.PackagesInstalled,
ContainersUpdated: result.ContainersUpdated,
Dependencies: result.Dependencies,
IsDryRun: true,
},
Closure: closure,
}
if err := apiClient.ReportDependencies(cfg.AgentID, depReport); err != nil {
log.Printf("[ERROR] [agent] [installer] report_dependencies_failed error=%v", err)
return fmt.Errorf("failed to report dependencies: %w", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: "dry_run",
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
if len(result.Dependencies) > 0 {
logReport.Stdout += fmt.Sprintf("\nDependencies found: %v", result.Dependencies)
}
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
log.Printf("[WARNING] [agent] [installer] report_dry_run_failed error=%v", reportErr)
}
log.Printf("[INFO] [agent] [installer] dry_run_complete package=%s deps=%d duration=%ds",
packageName, len(result.Dependencies), result.DurationSeconds)
return nil
}
// resolveClosureHashes resolves the canonical SHA256 for the top-level package
// and each dependency via the agent's signed repo metadata. The top-level entry
// is required: if it cannot be resolved the closure is dropped entirely (no
// partial pin). Dependencies are best-effort and logged on failure. Returns nil
// for package types whose hash is sourced server-side (npm/PyPI) or unsupported.
func resolveClosureHashes(packageType, packageName, targetVersion string, dependencies []string) []client.ClosureItem {
top, err := installer.ResolveArtifactSHA256(packageType, packageName, targetVersion)
if err != nil {
log.Printf("[WARNING] [agent] [installer] closure_toplevel_unresolved pkg=%s type=%s error=%v",
packageName, packageType, err)
return nil
}
closure := []client.ClosureItem{{
Name: top.Name,
Version: top.Version,
SHA256: top.SHA256,
Source: "registry",
}}
for _, dep := range dependencies {
if dep == "" || dep == packageName {
continue
}
resolved, err := installer.ResolveArtifactSHA256(packageType, dep, "")
if err != nil {
log.Printf("[WARNING] [agent] [installer] closure_dependency_unresolved dep=%s type=%s error=%v",
dep, packageType, err)
continue
}
closure = append(closure, client.ClosureItem{
Name: resolved.Name,
Version: resolved.Version,
SHA256: resolved.SHA256,
Source: "registry",
})
}
log.Printf("[INFO] [agent] [installer] closure_resolved pkg=%s type=%s entries=%d",
packageName, packageType, len(closure))
return closure
}
// HandleConfirmDependencies installs a package together with the dependencies
// the operator confirmed during the dry-run review. Empty dependency list is
// allowed — that path resolves to a simple UpdatePackage on the named target.
func HandleConfirmDependencies(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
// Every error return below surfaces on the History channel as a failed
// install event (ETHOS #1), inward via the operational event buffer.
defer func() {
if err != nil {
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
}
}()
if packageType == "" || packageName == "" {
return fmt.Errorf("package_type and package_name parameters are required")
}
// Gated ecosystems — confirmation goes through capability-token path.
if packageType == "dnf" || packageType == "apt" {
return fmt.Errorf("[SECURITY] [agent] [installer] direct_confirm_refused type=%s — confirmation must go through capability-token path", packageType)
}
var dependencies []string
if deps, ok := params["dependencies"].([]interface{}); ok {
for _, dep := range deps {
if s, ok := dep.(string); ok && s != "" {
dependencies = append(dependencies, s)
}
}
}
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
}
if !inst.IsAvailable() {
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
}
var result *installer.InstallResult
var action string
// Non-gated ecosystems mutate directly. dnf/apt do not implement
// NonGatedInstaller, so the assertion fails for them — the gate boundary
// is structural here, not a packageType guard.
mut, ok := inst.(installer.NonGatedInstaller)
if !ok {
return fmt.Errorf("[ERROR] [agent] [installer] direct_confirm_not_supported type=%s", packageType)
}
if len(dependencies) > 0 {
action = "install_with_dependencies"
log.Printf("[INFO] [agent] [installer] install_with_deps package=%s deps=%v", packageName, dependencies)
allPackages := append([]string{packageName}, dependencies...)
result, err = mut.InstallMultiple(allPackages)
} else {
action = "update"
log.Printf("[INFO] [agent] [installer] update_package package=%s", packageName)
result, err = mut.UpdatePackage(packageName)
}
if err != nil {
stdout, stderr, exitCode, duration := "", err.Error(), 1, 0
if result != nil {
stdout, stderr, exitCode, duration = result.Stdout, result.Stderr, result.ExitCode, result.DurationSeconds
}
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: duration,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("installation failed: %w", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: result.Action,
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
if len(result.PackagesInstalled) > 0 {
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
}
if len(dependencies) > 0 {
logReport.Stdout += fmt.Sprintf("\nDependencies included: %v", dependencies)
}
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
log.Printf("[WARNING] [agent] [installer] report_install_failed error=%v", reportErr)
}
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
fmt.Sprintf("%s of %s completed in %ds", action, packageName, result.DurationSeconds))
log.Printf("[INFO] [agent] [installer] install_complete action=%s package=%s duration=%ds",
action, packageName, result.DurationSeconds)
return nil
}

View file

@ -0,0 +1,72 @@
package handlers
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// HandleEnableHeartbeat flips the agent into rapid-polling mode for the
// duration the server requested. The polling loop reads RapidPollingEnabled
// and RapidPollingUntil on every iteration (see agent/loop.go), so persisting
// the change to config is enough — the next loop tick picks it up.
func HandleEnableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, params map[string]interface{}, commandID string) error {
durationMinutes := 60
if d, ok := params["duration_minutes"].(float64); ok && d > 0 {
durationMinutes = int(d)
}
cfg.RapidPollingEnabled = true
cfg.RapidPollingUntil = time.Now().UTC().Add(time.Duration(durationMinutes) * time.Minute)
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: "enable_heartbeat",
Result: "success",
Stdout: fmt.Sprintf("Heartbeat enabled for %d minutes", durationMinutes),
ExitCode: 0,
DurationSeconds: 0,
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] report_enable_failed error=%v", err)
}
log.Printf("[INFO] [agent] [heartbeat] enabled duration_minutes=%d until=%s",
durationMinutes, cfg.RapidPollingUntil.Format(time.RFC3339))
return nil
}
// HandleDisableHeartbeat clears rapid-polling state. Called by the server when
// a multi-step flow finishes (or by the timeout reconciler if the flow stalls).
func HandleDisableHeartbeat(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
cfg.RapidPollingEnabled = false
cfg.RapidPollingUntil = time.Time{}
if err := cfg.Save(constants.GetAgentConfigPath()); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] config_save_failed error=%v", err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: "disable_heartbeat",
Result: "success",
Stdout: "Heartbeat disabled",
ExitCode: 0,
DurationSeconds: 0,
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[WARNING] [agent] [heartbeat] report_disable_failed error=%v", err)
}
log.Printf("[INFO] [agent] [heartbeat] disabled")
return nil
}

View file

@ -0,0 +1,180 @@
package handlers
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/gofrs/uuid/v5"
)
// emitInstallEvent records a package-install outcome on the system-event
// channel so it reaches the History page (ETHOS #1: errors are history).
// It routes inward through the operational event buffer, which persists to disk
// and flushes to the server independently of the command-result path. A
// failed install is auditable even if the result report is lost.
func emitInstallEvent(buffer *event.Buffer, agentID uuid.UUID, subtype, severity, packageType, packageName, commandID, message string) {
event.BufferSystemEvent(buffer, agentID,
models.EventTypeAgentInstall, subtype, severity,
packageType+"_installer", message,
map[string]interface{}{
"package_type": packageType,
"package_name": packageName,
"command_id": commandID,
},
)
}
// ExpectedHashes maps package names to their expected SHA256 hashes
func ExpectedHashes(cfg *config.Config) map[string]string {
hashes := make(map[string]string)
if cfg.PackageHashes != nil {
for name, hash := range cfg.PackageHashes {
hashes[name] = hash
}
}
return hashes
}
// FetchExpectedHash retrieves the expected SHA256 hash for a package from the server
func FetchExpectedHash(apiClient *client.Client, cfg *config.Config, packageName, version, packageType string) (string, error) {
return apiClient.GetExpectedHash(packageType, packageName, cfg.AgentID)
}
func HandleInstallUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, eventBuffer *event.Buffer, params map[string]interface{}, commandID string) (err error) {
packageType, _ := params["package_type"].(string)
packageName, _ := params["package_name"].(string)
// Any error return below lands on the History channel as a failed install
// event — one inward funnel covering every failure path (factory, hash
// verification, the install itself).
defer func() {
if err != nil {
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeFailed, models.SeverityError, packageType, packageName, commandID, err.Error())
}
}()
if packageType == "" {
return fmt.Errorf("package_type parameter is required")
}
inst, err := installer.InstallerFactory(packageType, cfg.ServerURL)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", packageType, err)
}
if !inst.IsAvailable() {
return fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", packageType)
}
// Layer 1: Hash Registry — verify package hash before installation
// Fetch the expected hash from the server
expectedHash, err := FetchExpectedHash(apiClient, cfg, packageName, "", packageType)
if err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] hash_fetch_failed package=%s error=%w", packageName, err)
}
if expectedHash != "" {
log.Printf("[INFO] [agent] [installer] verifying_hash package=%s expected_sha256=%s", packageName, expectedHash[:16]+"...")
if err := inst.VerifyHash(packageName, "", expectedHash); err != nil {
return fmt.Errorf("[ERROR] [agent] [installer] hash_verification_failed package=%s error=%w", packageName, err)
}
log.Printf("[INFO] [agent] [installer] hash_verified package=%s", packageName)
}
var result *installer.InstallResult
var action string
startTime := time.Now()
// NEW: Check cache first for expected hash
hashCache := cache.NewHashCache(100) // 100 entries max
if cachedHash, cached := hashCache.Get(packageType, packageName, ""); cached {
log.Printf("[INFO] [agent] [installer] hash_cached package=%s hash=%s", packageName, cachedHash[:16]+"...")
}
// Gated ecosystems (dnf, apt) — mutation is through capability-token path only.
if packageType == "dnf" || packageType == "apt" {
return fmt.Errorf("[SECURITY] [agent] [installer] direct_mutation_refused type=%s — mutation must go through capability-token path", packageType)
}
// Non-gated ecosystems mutate directly. dnf/apt are excluded above and
// do not implement NonGatedInstaller, so the assertion fails for them.
mut, ok := inst.(installer.NonGatedInstaller)
if !ok {
return fmt.Errorf("[ERROR] [agent] [installer] direct_mutation_not_supported type=%s", packageType)
}
if packageName != "" {
action = "update"
log.Printf("[INFO] [agent] [installer] updating_package package=%s type=%s", packageName, packageType)
result, err = mut.UpdatePackage(packageName)
} else {
action = "upgrade"
log.Printf("[INFO] [agent] [installer] upgrading_all type=%s", packageType)
result, err = mut.Upgrade()
}
duration := int(time.Since(startTime).Seconds())
if err != nil {
stdout := ""
stderr := err.Error()
exitCode := 1
if result != nil {
stdout = result.Stdout
stderr = result.Stderr
exitCode = result.ExitCode
}
logReport := client.LogReport{
CommandID: commandID,
Action: action,
Result: "failed",
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: duration,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("[ERROR] [agent] [installer] install_failed action=%s type=%s error=%w", action, packageType, err)
}
logReport := client.LogReport{
CommandID: commandID,
Action: result.Action,
Result: "success",
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: result.DurationSeconds,
Metadata: map[string]string{
"subsystem_label": "Package Install",
"subsystem": packageType,
},
}
if len(result.PackagesInstalled) > 0 {
logReport.Stdout += fmt.Sprintf("\nPackages installed: %v", result.PackagesInstalled)
}
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); reportErr != nil {
log.Printf("[WARNING] [agent] [installer] report_failed error=%v", reportErr)
}
emitInstallEvent(eventBuffer, cfg.AgentID, models.SubtypeSuccess, models.SeverityInfo, packageType, packageName, commandID,
fmt.Sprintf("%s of %s completed in %ds", result.Action, packageName, duration))
log.Printf("[INFO] [agent] [installer] install_complete action=%s type=%s duration=%ds", action, packageType, duration)
return nil
}

View file

@ -0,0 +1,272 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
"github.com/gofrs/uuid/v5"
)
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
// dry-run closure resolve → hash pin → OSV best-effort → mint request → the
// privileged helper signs with the root-owned authority → the normal execute
// path verifies and installs. Fleet mode refuses: the server is the sole
// authority there, and this path never weakens that.
// Typed failures the local API maps to honest HTTP codes.
var (
// ErrApprovalFleetMode: this host is registered to a fleet server.
ErrApprovalFleetMode = errors.New("local approval refused: fleet mode — approve via the server")
// ErrApprovalBlocked: a gate refused and no override reason was supplied.
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
// ErrApprovalNoStandaloneIdentity: local authority was not provisioned.
ErrApprovalNoStandaloneIdentity = errors.New("local approval refused: standalone identity is not provisioned")
)
// LocalApproveRequest is RedFlag Desktop's approval submission.
type LocalApproveRequest struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
AvailableVersion string `json:"available_version"`
Operator string `json:"operator"`
OverrideReason string `json:"override_reason"`
}
// LocalApproveResult carries the gate verdicts plus the execution outcome so
// Desktop can render exactly what was checked and what happened.
type LocalApproveResult struct {
RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
ClosureSize int `json:"closure_size"`
Policy *supplychain.PolicyResult `json:"policy,omitempty"`
Receipt *capability.MutationReceipt `json:"receipt,omitempty"`
}
// gatedLocalApproval limits local approval to the ecosystems whose closures
// the agent can resolve and pin from signed repo metadata. Mirrors the
// capability-gate set, not the full installer set.
func gatedLocalApproval(packageType string) bool {
return packageType == "dnf" || packageType == "apt" || packageType == "pacman"
}
type pacmanExecutionLocation struct {
Kind string `json:"kind"`
Value string `json:"value"`
}
type pacmanActionPayload struct {
ArtifactSHA256 string `json:"artifact_sha256"`
ExecutionLocation pacmanExecutionLocation `json:"execution_location"`
Repository string `json:"repository"`
Requested bool `json:"requested"`
SignatureSHA256 string `json:"signature_sha256"`
SignatureLocation pacmanExecutionLocation `json:"signature_location"`
}
func handleLocalPacmanApproval(
ctx context.Context,
cfg *config.Config,
req LocalApproveRequest,
) (*LocalApproveResult, error) {
// OSV has no Arch ecosystem mapping in RedFlag. Preserve that as an
// unsupported gate, which requires the same recorded operator override as
// an outage. Refuse before network and artifact work when intent is absent.
osvStatus := supplychain.OSVStatusUnsupported
if req.OverrideReason == "" {
return nil, fmt.Errorf("%w: osv_status=%s — override requires an explicit reason",
ErrApprovalBlocked, osvStatus)
}
resolution, err := installer.ResolvePacmanClosure(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("resolve pacman closure: %w", err)
}
defer resolution.Cleanup()
resolvedAt := time.Now().UTC()
actions := make([]capability.ResolvedAction, 0, len(resolution.Artifacts))
evidence := make([]capability.Evidence, 0, len(resolution.Artifacts)*2)
for _, artifact := range resolution.Artifacts {
payload, err := json.Marshal(pacmanActionPayload{
ArtifactSHA256: artifact.ArchiveSHA256,
ExecutionLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.ArchivePath},
Repository: artifact.Repository,
Requested: artifact.Name == req.PackageName && (req.AvailableVersion == "" || artifact.Version == req.AvailableVersion),
SignatureSHA256: artifact.SignatureSHA256,
SignatureLocation: pacmanExecutionLocation{Kind: "cache", Value: artifact.SignaturePath},
})
if err != nil {
return nil, fmt.Errorf("encode pacman action %s: %w", artifact.Name, err)
}
actions = append(actions, capability.ResolvedAction{
Kind: "package", Identity: artifact.Name + "@" + artifact.Version, Payload: string(payload),
})
evidence = append(evidence,
capability.Evidence{Kind: "pacman-package-archive", Digest: artifact.ArchiveSHA256},
capability.Evidence{Kind: "pacman-package-signature", Digest: artifact.SignatureSHA256},
)
}
operationID, err := uuid.NewV4()
if err != nil {
return nil, fmt.Errorf("generate pacman operation id: %w", err)
}
manifest := capability.MutationManifest{
ProtocolVersion: capability.MutationProtocolVersion,
OperationID: operationID.String(),
TargetID: cfg.AgentID.String(),
Backend: "pacman",
Operation: "upgrade",
ResolvedActions: actions,
Evidence: evidence,
}
gateEvidence := supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: 0,
OSVStatus: osvStatus,
OSVVulnCount: 0,
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
}
executor := supplychain.NewExecutor("")
envelope, requestID, err := executor.MintEnvelope(ctx, manifest, gateEvidence)
if err != nil {
return nil, err
}
receipt, err := executor.ExecuteEnvelope(ctx, envelope)
if err != nil {
return nil, fmt.Errorf("execute pacman envelope authorization_id=%s: %w",
envelope.Authorization.AuthorizationID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d authorization_id=%s",
req.PackageName, receipt.Decision, receipt.ExitCode, receipt.AuthorizationID)
return &LocalApproveResult{
RequestID: requestID,
OSVStatus: osvStatus,
OSVVulnCount: 0,
ClosureSize: len(resolution.Artifacts),
Receipt: receipt,
}, nil
}
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
// the caller holds the local socket connection until the helper's verdict (and,
// on success, the install) completes.
func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApproveRequest) (*LocalApproveResult, error) {
if cfg.IsRegistered() {
return nil, ErrApprovalFleetMode
}
if !cfg.IsStandalone() {
return nil, ErrApprovalNoStandaloneIdentity
}
if req.PackageType == "" || req.PackageName == "" {
return nil, fmt.Errorf("package_type and package_name are required")
}
if !gatedLocalApproval(req.PackageType) {
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt|pacman only)", req.PackageType)
}
if req.Operator == "" {
return nil, fmt.Errorf("operator is required")
}
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
if req.PackageType == "pacman" {
return handleLocalPacmanApproval(ctx, cfg, req)
}
// Resolve the closure exactly as the fleet dry-run path does.
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", req.PackageType, err)
}
if !inst.IsAvailable() {
return nil, fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", req.PackageType)
}
dryRun, err := inst.DryRun(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("dry run failed: %w", err)
}
resolvedAt := time.Now().UTC()
closureItems := resolveClosureHashes(req.PackageType, req.PackageName, req.AvailableVersion, dryRun.Dependencies)
if len(closureItems) == 0 {
// No pin, no mint. The execute path could not verify anything.
return nil, fmt.Errorf("closure hash resolution failed for %s — refusing unpinned approval", req.PackageName)
}
closure := make([]capability.ClosureEntry, len(closureItems))
pkgs := make([]supplychain.PkgVersion, len(closureItems))
for i, c := range closureItems {
closure[i] = capability.ClosureEntry{
Name: c.Name,
Version: c.Version,
SHA256: c.SHA256,
Source: c.Source,
}
pkgs[i] = supplychain.PkgVersion{Name: c.Name, Version: c.Version}
}
// OSV best-effort with honest verdict: vulnerable or unreachable proceeds
// only over an explicit operator reason, refused here before the helper is
// ever invoked (the helper re-enforces — defense in depth).
osvStatus, vulnCount := supplychain.CheckClosureOSV(ctx, req.PackageType, pkgs)
osvCheckedAt := time.Now().UTC()
if osvStatus != supplychain.OSVStatusClear && req.OverrideReason == "" {
log.Printf("[SECURITY] [agent] [localapprove] approval_blocked pkg=%s osv_status=%s vulns=%d",
req.PackageName, osvStatus, vulnCount)
return nil, fmt.Errorf("%w: osv_status=%s vulns=%d — override requires an explicit reason",
ErrApprovalBlocked, osvStatus, vulnCount)
}
mintReq := &supplychain.MintRequest{
AgentID: cfg.AgentID.String(),
PackageType: req.PackageType,
Operation: "install",
Closure: closure,
GateEvidence: supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: osvCheckedAt.Unix(),
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
// Standalone has no registry age data for dnf/apt (matches the
// fleet age gate's ecosystem scope) and no local version
// first-seen tracking yet — journaled honestly as not_applicable.
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
},
}
executor := supplychain.NewExecutor("")
token, err := executor.Mint(ctx, mintReq)
if err != nil {
return nil, err
}
policy, err := executor.Execute(ctx, token)
if err != nil {
return nil, fmt.Errorf("execute after mint failed (token_id=%s): %w", token.TokenID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d",
req.PackageName, policy.Decision, policy.ExitCode)
return &LocalApproveResult{
RequestID: mintReq.RequestID,
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
ClosureSize: len(closure),
Policy: policy,
}, nil
}

View file

@ -0,0 +1,49 @@
package handlers
import (
"context"
"errors"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/gofrs/uuid/v5"
)
func TestGatedLocalApprovalEcosystems(t *testing.T) {
for _, packageType := range []string{"apt", "dnf", "pacman"} {
if !gatedLocalApproval(packageType) {
t.Errorf("%s is not routed through local authority", packageType)
}
}
for _, packageType := range []string{"docker", "winget", "windows_update", "cargo"} {
if gatedLocalApproval(packageType) {
t.Errorf("%s entered local authority without a migrated backend", packageType)
}
}
}
func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
})
if !errors.Is(err, ErrApprovalFleetMode) {
t.Fatalf("fleet approval error = %v", err)
}
}
func TestPacmanUnsupportedOSVRequiresReasonBeforeResolution(t *testing.T) {
id, err := uuid.NewV4()
if err != nil {
t.Fatal(err)
}
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id}, LocalApproveRequest{
PackageType: "pacman", PackageName: "not-a-real-package", Operator: "operator",
})
if !errors.Is(err, ErrApprovalBlocked) {
t.Fatalf("pacman approval error = %v", err)
}
}

View file

@ -0,0 +1,60 @@
package handlers
import (
"log"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
)
func persistLocalState(cfg *config.Config, update func(*cache.LocalCache)) {
localCache, err := cache.Load()
if err != nil {
log.Printf("[WARNING] [agent] [local_state] load_failed error=%v", err)
localCache = &cache.LocalCache{}
}
applyLocalIdentity(localCache, cfg)
if update != nil {
update(localCache)
}
if err := localCache.Save(); err != nil {
log.Printf("[WARNING] [agent] [local_state] save_failed error=%v", err)
}
}
func applyLocalIdentity(localCache *cache.LocalCache, cfg *config.Config) {
if localCache == nil || cfg == nil || !cfg.IsRegistered() {
return
}
localCache.SetAgentInfo(cfg.AgentID, cfg.ServerURL)
}
func recordLocalFullScan(cfg *config.Config, updates []client.UpdateReportItem, results []orchestrator.ScanResult) {
persistLocalState(cfg, func(localCache *cache.LocalCache) {
localCache.SetAgentStatus("online")
localCache.UpdateScanResults(updates)
for _, result := range results {
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, false)
}
})
}
func recordLocalScanResult(cfg *config.Config, result orchestrator.ScanResult, affectsUpdateList bool) {
persistLocalState(cfg, func(localCache *cache.LocalCache) {
localCache.SetAgentStatus("online")
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, affectsUpdateList)
})
}
func recordLocalScanResults(cfg *config.Config, results []orchestrator.ScanResult, affectsUpdateList bool) {
persistLocalState(cfg, func(localCache *cache.LocalCache) {
localCache.SetAgentStatus("online")
for _, result := range results {
localCache.RecordScannerResult(result.ScannerName, result.Status, result.Updates, result.Error, result.Duration, affectsUpdateList)
}
})
}

View file

@ -0,0 +1,84 @@
package handlers
import (
"context"
"fmt"
"log"
"runtime"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
)
// HandleLocalTriggeredScan runs the full package-update scan on behalf of the
// local API write path (FEAT-002). There is no signed server command behind
// it, so no command ID exists and nothing is ack-tracked.
//
// Registered agents run the same HandleScanUpdates path a server scan command
// uses — results are reported so the server ingests and reconciles them
// (RECONCILE-001 close-by-absence included). Unregistered (standalone) agents
// run the scanners through the orchestrator and record the local read model
// only; no doomed HTTP calls are attempted.
func HandleLocalTriggeredScan(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, source string) error {
log.Printf("[INFO] [agent] [localapi] local_scan_started source=%s registered=%v", source, cfg.IsRegistered())
if cfg.IsRegistered() {
return HandleScanUpdates(apiClient, cfg, ackTracker, orch, "")
}
return runStandaloneUpdateScan(cfg, orch)
}
// runStandaloneUpdateScan mirrors HandleScanUpdates's scanner set (the virtual
// "updates" subsystem) without any server reporting: orchestrator-managed
// scans (circuit breakers, timeouts) feeding the local read model.
func runStandaloneUpdateScan(cfg *config.Config, orch *orchestrator.Orchestrator) error {
ctx := context.Background()
var results []orchestrator.ScanResult
var errs []string
type updateScanner struct {
name string
available func() bool
}
var candidates []updateScanner
switch runtime.GOOS {
case "linux":
candidates = []updateScanner{
{"apt", scanner.NewAPTScanner().IsAvailable},
{"dnf", scanner.NewDNFScanner().IsAvailable},
{"pacman", scanner.NewPacmanScanner().IsAvailable},
}
case "windows":
candidates = []updateScanner{
{"windows", scanner.NewWindowsUpdateScanner().IsAvailable},
{"winget", scanner.NewWingetScanner().IsAvailable},
}
}
ran := 0
for _, cand := range candidates {
if !cand.available() {
continue
}
ran++
result, err := orch.ScanSingle(ctx, cand.name)
results = append(results, result)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", cand.name, err))
}
}
recordLocalScanResults(cfg, results, true)
if len(errs) > 0 {
err := fmt.Errorf("standalone scan errors: %s", strings.Join(errs, "; "))
log.Printf("[ERROR] [agent] [localapi] standalone_scan_failed scanners_run=%d error=%v", ran, err)
return err
}
log.Printf("[INFO] [agent] [localapi] standalone_scan_completed scanners_run=%d", ran)
return nil
}

View file

@ -0,0 +1,62 @@
package handlers
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/system"
)
// HandleScanProcesses performs a full on-demand process scan and reports
// the results to the server. Triggered by the "scan_processes" command
// (issued when a user opens the Processes tab in the dashboard).
func HandleScanProcesses(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
log.Printf("[INFO] [agent] [processes] scan_started command_id=%s", commandID)
startTime := time.Now()
snapshot, err := system.GetFullProcessSnapshot()
duration := time.Since(startTime)
if err != nil {
log.Printf("[ERROR] [agent] [processes] scan_failed command_id=%s duration=%s error=%v", commandID, duration, err)
return fmt.Errorf("process scan failed: %w", err)
}
log.Printf("[INFO] [agent] [processes] scan_complete command_id=%s process_count=%d duration=%s", commandID, snapshot.ProcessCount, duration)
// Report to server
report := client.ProcessScanReport{
AgentID: cfg.AgentID,
CommandID: commandID,
Timestamp: time.Now().UTC(),
Snapshot: *snapshot,
}
if err := apiClient.ReportProcessScan(cfg.AgentID, report); err != nil {
log.Printf("[ERROR] [agent] [processes] report_failed command_id=%s error=%v", commandID, err)
return fmt.Errorf("failed to report process scan: %w", err)
}
// Audit log
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_processes",
Result: "success",
ExitCode: 0,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Processes",
"subsystem": "processes",
"process_count": fmt.Sprintf("%d", snapshot.ProcessCount),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [processes] report_log_failed: %v", err)
}
return nil
}

View file

@ -0,0 +1,81 @@
package handlers
import (
"fmt"
"log"
"os/exec"
"runtime"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// HandleReboot schedules a system reboot in response to a server-issued
// `reboot` command. Ported from the pre-TD-001 main.go (commit 9da5134e^)
// where it was lost in the god-function split. The server-side endpoint
// (POST /api/v1/agents/:id/reboot) creates the command; without this
// handler the agent silently drops it in dispatch's default case.
func HandleReboot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string, params map[string]interface{}) error {
delayMinutes := 1
message := "System reboot requested by RedFlag"
if delay, ok := params["delay_minutes"]; ok {
if delayFloat, ok := delay.(float64); ok {
delayMinutes = int(delayFloat)
}
}
if msg, ok := params["message"].(string); ok && msg != "" {
message = msg
}
log.Printf("[INFO] [agent] [reboot] scheduled delay_minutes=%d message=%q", delayMinutes, message)
var cmd *exec.Cmd
switch runtime.GOOS {
case "linux":
cmd = exec.Command("shutdown", "-r", fmt.Sprintf("+%d", delayMinutes), message)
case "windows":
delaySeconds := delayMinutes * 60
cmd = exec.Command("shutdown", "/r", "/t", fmt.Sprintf("%d", delaySeconds), "/c", message)
default:
err := fmt.Errorf("reboot not supported on platform: %s", runtime.GOOS)
log.Printf("[ERROR] [agent] [reboot] unsupported_platform os=%s", runtime.GOOS)
ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
CommandID: commandID,
Action: "reboot",
Result: "failed",
Stderr: err.Error(),
ExitCode: 1,
})
return err
}
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("[ERROR] [agent] [reboot] schedule_failed error=%v output=%q", err, string(output))
ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
CommandID: commandID,
Action: "reboot",
Result: "failed",
Stdout: string(output),
Stderr: err.Error(),
ExitCode: 1,
})
return err
}
log.Printf("[INFO] [agent] [reboot] scheduled_ok delay_minutes=%d", delayMinutes)
if reportErr := ReportLogWithAck(apiClient, cfg, ackTracker, client.LogReport{
CommandID: commandID,
Action: "reboot",
Result: "success",
Stdout: fmt.Sprintf("System reboot scheduled for %d minute(s) from now. Message: %s", delayMinutes, message),
ExitCode: 0,
}); reportErr != nil {
log.Printf("[ERROR] [agent] [reboot] report_failed error=%v", reportErr)
}
return nil
}

View file

@ -0,0 +1,742 @@
package handlers
import (
"context"
"fmt"
"log"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/orchestrator"
"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.
// A log with no command ID (locally triggered scan — no signed server command behind it)
// is reported without ack tracking: there is no command completion to guarantee.
func reportLogWithAck(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, logReport client.LogReport) error {
if logReport.CommandID == "" {
return apiClient.ReportLog(cfg.AgentID, logReport)
}
// Track this command result as pending acknowledgment
ackTracker.Add(logReport.CommandID)
// Save acknowledgment state immediately
if err := ackTracker.Save(); err != nil {
log.Printf("Warning: Failed to save acknowledgment for command %s: %v", logReport.CommandID, err)
}
// Report the log to the server
if err := apiClient.ReportLog(cfg.AgentID, logReport); err != nil {
// If reporting failed, increment retry count but don't remove from pending
ackTracker.IncrementRetry(logReport.CommandID)
return err
}
return nil
}
// HandleScanUpdates scans for ALL package updates across all available package managers
// This is the virtual "updates" subsystem that triggers apt, dnf, winget, and windows scans
func HandleScanUpdates(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning for all package updates...")
ctx := context.Background()
startTime := time.Now()
var totalUpdates int
var scanResults []string
var localResults []orchestrator.ScanResult
var errors []string
// Detect OS and run appropriate scanners
osType := runtime.GOOS
// Linux: Try APT first, then DNF
if osType == "linux" {
// Try APT
aptScanner := scanner.NewAPTScanner()
if aptScanner.IsAvailable() {
log.Println("[updates] Running APT scan...")
result, err := orch.ScanSingle(ctx, "apt")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("APT: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("APT: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// RECONCILE-001: always report on a successful scan, even 0 updates,
// so the server can close rows absent from this scan.
if result.Status == "success" {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
Ecosystem: "apt",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [updates] apt_report_failed error=%v", err)
}
}
}
}
// Try DNF
dnfScanner := scanner.NewDNFScanner()
if dnfScanner.IsAvailable() {
log.Println("[updates] Running DNF scan...")
result, err := orch.ScanSingle(ctx, "dnf")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("DNF: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("DNF: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// RECONCILE-001: always report on a successful scan, even 0 updates,
// so the server can close rows absent from this scan.
if result.Status == "success" {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
Ecosystem: "dnf",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [updates] dnf_report_failed error=%v", err)
}
}
}
}
}
// Windows: Try Windows Update and Winget
if osType == "windows" {
// Try Windows Update
windowsScanner := scanner.NewWindowsUpdateScanner()
if windowsScanner.IsAvailable() {
log.Println("[updates] Running Windows Update scan...")
result, err := orch.ScanSingle(ctx, "windows")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("Windows: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("Windows: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// Report Windows updates
if len(result.Updates) > 0 {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [updates] Failed to report Windows updates: %v", err)
}
}
}
}
// Try Winget
wingetScanner := scanner.NewWingetScanner()
if wingetScanner.IsAvailable() {
log.Println("[updates] Running Winget scan...")
result, err := orch.ScanSingle(ctx, "winget")
localResults = append(localResults, result)
if err != nil {
errors = append(errors, fmt.Sprintf("Winget: %v", err))
} else {
scanResults = append(scanResults, fmt.Sprintf("Winget: %d updates", len(result.Updates)))
totalUpdates += len(result.Updates)
// Report Winget updates
if len(result.Updates) > 0 {
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: result.Updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [updates] Failed to report Winget updates: %v", err)
}
}
}
}
}
recordLocalScanResults(cfg, localResults, true)
duration := time.Since(startTime)
stdout := fmt.Sprintf("Package update scan completed in %.2f seconds\n\nResults:\n%s\n\nTotal updates found: %d",
duration.Seconds(),
strings.Join(scanResults, "\n"),
totalUpdates)
stderr := ""
exitCode := 0
if len(errors) > 0 {
stderr = fmt.Sprintf("Errors encountered:\n%s", strings.Join(errors, "\n"))
exitCode = 1
}
// Create history entry
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_updates",
Result: map[bool]string{true: "success", false: "partial_failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Package Updates",
"subsystem": "updates",
"total_updates": fmt.Sprintf("%d", totalUpdates),
"scanners_run": fmt.Sprintf("%d", len(scanResults)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [updates] report_log_failed: %v", err)
} else {
log.Printf("[INFO] [agent] [updates] scan completed: %d updates found", totalUpdates)
}
return nil
}
// HandleScanStorage scans disk usage metrics only
func HandleScanStorage(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning storage...")
ctx := context.Background()
startTime := time.Now()
result, err := orch.ScanSingle(ctx, "storage")
if err != nil {
return fmt.Errorf("failed to scan storage: %w", err)
}
recordLocalScanResult(cfg, result, false)
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nStorage scan completed in %.2f seconds\n", duration.Seconds())
// Report storage metrics to server using dedicated endpoint
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,
})
}
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))
}
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_storage",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Disk Usage",
"subsystem": "storage",
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [storage] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [storage] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [storage] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_storage] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanSystem scans system metrics (CPU, memory, processes, uptime)
func HandleScanSystem(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning system metrics...")
ctx := context.Background()
startTime := time.Now()
result, err := orch.ScanSingle(ctx, "system")
if err != nil {
return fmt.Errorf("failed to scan system: %w", err)
}
recordLocalScanResult(cfg, result, false)
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nSystem scan completed in %.2f seconds\n", duration.Seconds())
// Report system metrics to server using dedicated endpoint
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,
})
}
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))
}
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_system",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "System Metrics",
"subsystem": "system",
"metrics_count": fmt.Sprintf("%d", len(result.Updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [system] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [system] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [system] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_system] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanDocker scans Docker image inventory and container enrichment data.
// Uses the InventoryScanner path for image inventory, and the existing
// DockerReport path for container/stack/engine enrichment.
func HandleScanDocker(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning Docker images...")
ctx := context.Background()
startTime := time.Now()
// Use inventory scanner path for image data
invResult, err := orch.ScanInventorySingle(ctx, "docker")
if err != nil {
return fmt.Errorf("failed to scan Docker inventory: %w", err)
}
duration := time.Since(startTime)
// Report inventory items to the new inventory endpoint
if len(invResult.Items) > 0 {
invReport := client.InventoryReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Ecosystem: "docker",
Items: invResult.Items,
ScanSucceeded: invResult.Status == "success",
}
if err := apiClient.ReportInventory(cfg.AgentID, invReport); err != nil {
log.Printf("[WARNING] [agent] [docker] report_inventory_failed error=%v", err)
} else {
log.Printf("[INFO] [agent] [docker] Reported %d Docker images to inventory", len(invResult.Items))
}
}
// Collect container/stack enrichment data (existing DockerReport path)
updateCount := 0
for _, item := range invResult.Items {
if hasUpdate, ok := item.Metadata["has_update"].(bool); ok && hasUpdate {
updateCount++
}
}
dockerScanner, err := orchestrator.NewDockerScanner()
if err != nil {
log.Printf("[WARN] [agent] [docker] could not create scanner for enrichment: %v", err)
} else {
defer dockerScanner.Close()
containers, err := dockerScanner.ScanContainers()
if err != nil {
log.Printf("[WARN] [agent] [docker] container scan failed: %v", err)
} else {
report := client.DockerReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Containers: containers,
Stacks: dockerScanner.ScanStacks(containers),
EngineVersion: dockerScanner.GetEngineVersion(),
}
if err := apiClient.ReportDockerImages(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [docker] report_enrichment_failed error=%v", err)
} else {
log.Printf("[INFO] [agent] [docker] Reported %d containers, %d stacks for enrichment",
len(report.Containers), len(report.Stacks))
}
}
}
// Build log report
stdout := fmt.Sprintf("Docker inventory scan completed in %.2f seconds\n\nItems found: %d\nUpdates available: %d",
duration.Seconds(), len(invResult.Items), updateCount)
stderr := ""
exitCode := 0
if invResult.Status == "failed" {
stderr = fmt.Sprintf("Error: %v", invResult.Error)
exitCode = 1
}
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_docker",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Docker Images",
"subsystem": "docker",
"images_count": fmt.Sprintf("%d", len(invResult.Items)),
"updates_found": fmt.Sprintf("%d", updateCount),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [docker] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [docker] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [docker] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_docker] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanAPT scans APT package updates only
func HandleScanAPT(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning APT packages...")
ctx := context.Background()
startTime := time.Now()
// Execute APT scanner
result, err := orch.ScanSingle(ctx, "apt")
if err != nil {
return fmt.Errorf("failed to scan APT: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nAPT scan completed in %.2f seconds\n", duration.Seconds())
// Report APT updates to server.
// RECONCILE-001: always report on a successful scan, even when 0 updates are
// found. An empty reported set + ScanSucceeded=true tells the server this
// ecosystem is fully patched — it must close all tracked non-resting rows.
// Declare updates at function scope for ReportLog access.
var updates []client.UpdateReportItem
if result.Status == "success" {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
Ecosystem: "apt",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [apt] report_updates_failed error=%v", err)
// Do not return error here — the scan succeeded locally; the report failure
// is a transport problem. ReportLog below will still record the scan.
} else {
log.Printf("[INFO] [agent] [apt] reported %d APT updates to server", len(updates))
}
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_apt",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "APT Packages",
"subsystem": "apt",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [apt] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [apt] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [apt] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_apt] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanDNF scans DNF package updates only
func HandleScanDNF(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning DNF packages...")
ctx := context.Background()
startTime := time.Now()
// Execute DNF scanner
result, err := orch.ScanSingle(ctx, "dnf")
if err != nil {
return fmt.Errorf("failed to scan DNF: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nDNF scan completed in %.2f seconds\n", duration.Seconds())
// Report DNF updates to server.
// RECONCILE-001: always report on a successful scan, even when 0 updates are
// found. An empty reported set + ScanSucceeded=true tells the server this
// ecosystem is fully patched — it must close all tracked non-resting rows.
// Declare updates at function scope for ReportLog access.
var updates []client.UpdateReportItem
if result.Status == "success" {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
Ecosystem: "dnf",
ScanSucceeded: true,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
log.Printf("[WARNING] [agent] [dnf] report_updates_failed error=%v", err)
// Do not return error here — the scan succeeded locally; the report failure
// is a transport problem. ReportLog below will still record the scan.
} else {
log.Printf("[INFO] [agent] [dnf] reported %d DNF updates to server", len(updates))
}
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_dnf",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "DNF Packages",
"subsystem": "dnf",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [dnf] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [dnf] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [dnf] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_dnf] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanWindows scans Windows Updates only
func HandleScanWindows(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning Windows Updates...")
ctx := context.Background()
startTime := time.Now()
// Execute Windows Update scanner
result, err := orch.ScanSingle(ctx, "windows")
if err != nil {
return fmt.Errorf("failed to scan Windows Updates: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nWindows Update scan completed in %.2f seconds\n", duration.Seconds())
// Report Windows updates to server if any were found
// Declare updates at function scope for ReportLog access
var updates []client.UpdateReportItem
if result.Status == "success" && len(result.Updates) > 0 {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Windows updates: %w", err)
}
log.Printf("[INFO] [agent] [windows] Successfully reported %d Windows updates to server\n", len(updates))
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_windows",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Windows Updates",
"subsystem": "windows",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [windows] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [windows] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [windows] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_windows] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}
// HandleScanWinget scans Winget package updates only
func HandleScanWinget(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, orch *orchestrator.Orchestrator, commandID string) error {
log.Println("Scanning Winget packages...")
ctx := context.Background()
startTime := time.Now()
// Execute Winget scanner
result, err := orch.ScanSingle(ctx, "winget")
if err != nil {
return fmt.Errorf("failed to scan Winget: %w", err)
}
recordLocalScanResult(cfg, result, true)
// Format results
results := []orchestrator.ScanResult{result}
stdout, stderr, exitCode := orchestrator.FormatScanSummary(results)
duration := time.Since(startTime)
stdout += fmt.Sprintf("\nWinget scan completed in %.2f seconds\n", duration.Seconds())
// Report Winget updates to server if any were found
// Declare updates at function scope for ReportLog access
var updates []client.UpdateReportItem
if result.Status == "success" && len(result.Updates) > 0 {
updates = result.Updates
report := client.UpdateReport{
CommandID: commandID,
Timestamp: time.Now().UTC(),
Updates: updates,
}
if err := apiClient.ReportUpdates(cfg.AgentID, report); err != nil {
return fmt.Errorf("failed to report Winget updates: %w", err)
}
log.Printf("[INFO] [agent] [winget] Successfully reported %d Winget updates to server\n", len(updates))
}
// Create history entry for unified view with proper formatting
logReport := client.LogReport{
CommandID: commandID,
Action: "scan_winget",
Result: map[bool]string{true: "success", false: "failure"}[exitCode == 0],
Stdout: stdout,
Stderr: stderr,
ExitCode: exitCode,
DurationSeconds: int(duration.Seconds()),
Metadata: map[string]string{
"subsystem_label": "Winget Packages",
"subsystem": "winget",
"updates_found": fmt.Sprintf("%d", len(updates)),
},
}
if err := reportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
log.Printf("[ERROR] [agent] [winget] report_log_failed: %v", err)
log.Printf("[HISTORY] [agent] [winget] report_log_failed error=\"%v\" timestamp=%s", err, time.Now().UTC().Format(time.RFC3339))
} else {
log.Printf("[INFO] [agent] [winget] history_log_created command_id=%s timestamp=%s", commandID, time.Now().UTC().Format(time.RFC3339))
log.Printf("[HISTORY] [agent] [scan_winget] log_created agent_id=%s command_id=%s result=%s timestamp=%s", cfg.AgentID, commandID, map[bool]string{true: "success", false: "failure"}[exitCode == 0], time.Now().UTC().Format(time.RFC3339))
}
return nil
}

View file

@ -0,0 +1,381 @@
package handlers
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
)
// HandleCaptureScreenshot captures the current display output and reports it
// back as a base64-encoded PNG in the command result's stdout field.
// Observe-only: reads the screen, does not interact with it.
//
// Linux: uses scrot (X11) or import (ImageMagick) as fallback.
// Windows: uses PowerShell with .NET System.Drawing.
// The temp file is cleaned up after encoding.
func HandleCaptureScreenshot(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker, commandID string) error {
start := time.Now()
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("redflag_screenshot_%d.png", time.Now().UnixNano()))
defer os.Remove(tmpPath)
diag, err := captureScreen(tmpPath)
if err != nil {
// Report detailed diagnostics to the server instead of a generic message.
diagJSON, _ := json.Marshal(diag)
logReport := client.LogReport{
CommandID: commandID,
Action: "capture_screenshot",
Result: "failed",
Stderr: fmt.Sprintf("screen capture failed: %v", err),
ExitCode: 1,
Metadata: map[string]string{
"diagnostics": string(diagJSON),
},
DurationSeconds: int(time.Since(start).Seconds()),
}
_ = ReportLogWithAck(apiClient, cfg, ackTracker, logReport)
return fmt.Errorf("screen capture failed: %w", err)
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return fmt.Errorf("failed to read screenshot: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(data)
logReport := client.LogReport{
CommandID: commandID,
Action: "capture_screenshot",
Result: "success",
Stdout: encoded,
ExitCode: 0,
DurationSeconds: int(time.Since(start).Seconds()),
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, logReport); err != nil {
return fmt.Errorf("failed to report screenshot: %w", err)
}
log.Printf("[INFO] [agent] [screenshot] captured size_bytes=%d duration=%s",
len(data), time.Since(start).Round(time.Millisecond))
return nil
}
// captureScreen writes a PNG screenshot to outputPath. Platform-specific.
// Returns diagnostics for health reporting on failure.
func captureScreen(outputPath string) (*screenshotDiagnostics, error) {
switch runtime.GOOS {
case "linux":
return captureScreenLinux(outputPath)
case "windows":
if err := captureScreenWindows(outputPath); err != nil {
return &screenshotDiagnostics{SessionType: "windows"}, err
}
return &screenshotDiagnostics{SessionType: "windows"}, nil
default:
return &screenshotDiagnostics{}, fmt.Errorf("screenshot not supported on %s", runtime.GOOS)
}
}
// sessionDisplayInfo holds discovered display environment variables and the
// detected session type (x11, wayland, or unknown).
type sessionDisplayInfo struct {
env []string
sessionType string // "x11", "wayland", or ""
sourcePID int // pid whose environ provided the vars (0 = none)
}
// toolAttempt records one screenshot tool invocation for diagnostic reporting.
type toolAttempt struct {
Name string `json:"name"`
Found bool `json:"found"` // in PATH
Tried bool `json:"tried"` // actually executed
ExitCode int `json:"exit_code,omitempty"` // 0 = success, -1 = not tried
Stderr string `json:"stderr,omitempty"` // first 200 chars of output
}
// screenshotDiagnostics captures the full picture for the server when a
// screenshot fails. The generic "no tool found" message hid the real cause;
// this exposes it as structured health data.
type screenshotDiagnostics struct {
SessionType string `json:"session_type"`
SessionFound bool `json:"session_found"`
DisplayVars int `json:"display_vars"`
SessionPID int `json:"session_pid,omitempty"`
Tools []toolAttempt `json:"tools"`
CapSysPtrace bool `json:"cap_sys_ptrace"`
ProcReadable bool `json:"proc_readable"`
}
// captureScreenLinux captures the display. The agent service does not inherit
// DISPLAY/WAYLAND_DISPLAY from systemd, so we discover them from the running
// user session before invoking any capture tool.
//
// Tool priority depends on session type:
//
// X11: scrot → magick import → import (ImageMagick v6)
// Wayland: grim (wlroots) → gnome-screenshot (GNOME) → spectacle (KDE)
// → magick import (fallback, needs root on some compositors)
// Unknown: try all tools in order
func captureScreenLinux(outputPath string) (*screenshotDiagnostics, error) {
info := discoverSessionDisplay()
env := append(os.Environ(), info.env...)
diag := &screenshotDiagnostics{
SessionType: info.sessionType,
SessionFound: info.sessionType != "",
DisplayVars: len(info.env),
SessionPID: info.sourcePID,
CapSysPtrace: hasCapSysPtrace(),
ProcReadable: info.sourcePID != 0,
}
log.Printf("[INFO] [agent] [screenshot] session_type=%s display_vars=%d session_pid=%d cap_sys_ptrace=%v",
orDefault(info.sessionType, "unknown"), len(info.env), info.sourcePID, diag.CapSysPtrace)
type cmdFunc func(string, ...string) bool
var tryCmd cmdFunc
tryCmd = func(name string, args ...string) bool {
_, lookErr := exec.LookPath(name)
attempt := toolAttempt{Name: name, Found: lookErr == nil, ExitCode: -1}
if lookErr != nil {
diag.Tools = append(diag.Tools, attempt)
return false
}
attempt.Tried = true
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = env
if out, err := cmd.CombinedOutput(); err != nil {
attempt.Stderr = truncate(string(out), 200)
if exitErr, ok := err.(*exec.ExitError); ok {
attempt.ExitCode = exitErr.ExitCode()
}
log.Printf("[WARN] [agent] [screenshot] %s_failed exit=%d output=%q error=%v", name, attempt.ExitCode, string(out), err)
diag.Tools = append(diag.Tools, attempt)
return false
}
attempt.ExitCode = 0
diag.Tools = append(diag.Tools, attempt)
return true
}
var err error
switch info.sessionType {
case "wayland":
err = captureWayland(outputPath, env, tryCmd)
case "x11":
err = captureX11(outputPath, tryCmd)
default:
err = captureFallback(outputPath, tryCmd)
}
if err != nil {
return diag, err
}
return diag, nil
}
// captureX11 tries X11 screenshot tools in priority order.
func captureX11(outputPath string, tryCmd func(string, ...string) bool) error {
if tryCmd("scrot", "-o", outputPath) {
return nil
}
// ImageMagick v7: standalone `import` is gone, use `magick import`.
if tryCmd("magick", "import", "-window", "root", outputPath) {
return nil
}
// ImageMagick v6 compat.
if tryCmd("import", "-window", "root", outputPath) {
return nil
}
return fmt.Errorf("no X11 screenshot tool found (tried scrot, magick import)")
}
// captureWayland tries Wayland screenshot tools. Tool availability depends on
// the compositor:
// - wlroots (Sway, Hyprland, River): grim
// - GNOME (Mutter): gnome-screenshot or D-Bus org.gnome.Shell.Screenshot
// - KDE (KWin): spectacle --background --output
// - Fallback: magick import (may work under XWayland or as root)
func captureWayland(outputPath string, env []string, tryCmd func(string, ...string) bool) error {
// wlroots compositors — grim speaks wlr-screencopy / ext-image-capture-source.
if tryCmd("grim", outputPath) {
return nil
}
// GNOME Wayland — gnome-screenshot uses the GNOME Shell D-Bus API.
if tryCmd("gnome-screenshot", "-f", outputPath) {
return nil
}
// KDE Wayland — spectacle's --background flag captures without opening the GUI.
if tryCmd("spectacle", "--background", "--output", outputPath) {
return nil
}
// ImageMagick — may work via XWayland or with compositor-specific backends.
if tryCmd("magick", "import", "-window", "root", outputPath) {
return nil
}
if tryCmd("import", "-window", "root", outputPath) {
return nil
}
return fmt.Errorf("no Wayland screenshot tool found (tried grim, gnome-screenshot, spectacle, magick import)")
}
// captureFallback tries all tools regardless of session type.
func captureFallback(outputPath string, tryCmd func(string, ...string) bool) error {
if tryCmd("scrot", "-o", outputPath) {
return nil
}
if tryCmd("grim", outputPath) {
return nil
}
if tryCmd("gnome-screenshot", "-f", outputPath) {
return nil
}
if tryCmd("spectacle", "--background", "--output", outputPath) {
return nil
}
if tryCmd("magick", "import", "-window", "root", outputPath) {
return nil
}
if tryCmd("import", "-window", "root", outputPath) {
return nil
}
return fmt.Errorf("no screenshot tool found (tried scrot, grim, gnome-screenshot, spectacle, magick import)")
}
// discoverSessionDisplay reads /proc environ entries to find DISPLAY,
// WAYLAND_DISPLAY, XDG_RUNTIME_DIR, and XDG_SESSION_TYPE from an active
// user session. The agent service runs without these inherited from systemd.
//
// XDG_SESSION_TYPE is used to select the right screenshot tool chain:
// "x11" → scrot/magick, "wayland" → grim/gnome-screenshot/spectacle.
func discoverSessionDisplay() sessionDisplayInfo {
entries, err := os.ReadDir("/proc")
if err != nil {
return sessionDisplayInfo{}
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := strconv.Atoi(entry.Name()); err != nil {
continue
}
data, err := os.ReadFile("/proc/" + entry.Name() + "/environ")
if err != nil {
continue
}
var display, wayland, xdgRuntime, sessionType string
for _, v := range bytes.Split(data, []byte{0}) {
s := string(v)
switch {
case strings.HasPrefix(s, "DISPLAY=") && display == "":
display = s
case strings.HasPrefix(s, "WAYLAND_DISPLAY=") && wayland == "":
wayland = s
case strings.HasPrefix(s, "XDG_RUNTIME_DIR=") && xdgRuntime == "":
xdgRuntime = s
case strings.HasPrefix(s, "XDG_SESSION_TYPE=") && sessionType == "":
sessionType = strings.TrimPrefix(s, "XDG_SESSION_TYPE=")
}
}
if display != "" || wayland != "" {
var env []string
if display != "" {
env = append(env, display)
}
if wayland != "" {
env = append(env, wayland)
}
if xdgRuntime != "" {
env = append(env, xdgRuntime)
}
// Infer session type from env if XDG_SESSION_TYPE was not set.
if sessionType == "" {
if wayland != "" {
sessionType = "wayland"
} else if display != "" {
sessionType = "x11"
}
}
pid, _ := strconv.Atoi(entry.Name())
return sessionDisplayInfo{env: env, sessionType: sessionType, sourcePID: pid}
}
}
return sessionDisplayInfo{}
}
// orDefault returns s if non-empty, otherwise fallback.
func orDefault(s, fallback string) string {
if s != "" {
return s
}
return fallback
}
// hasCapSysPtrace checks if the current process has CAP_SYS_PTRACE in its
// effective set. Reads /proc/self/status to avoid a cgo dependency on libcap.
func hasCapSysPtrace() bool {
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "CapEff:") {
// CAP_SYS_PTRACE is bit 19 = 0x80000.
fields := strings.Fields(line)
if len(fields) >= 2 {
var cap uint64
fmt.Sscanf(fields[1], "%x", &cap)
return cap&0x80000 != 0
}
}
}
return false
}
// captureScreenWindows captures the display using PowerShell + .NET
// System.Drawing. No extra tools required — always available on Windows.
func captureScreenWindows(outputPath string) error {
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height)
$gfx = [System.Drawing.Graphics]::FromImage($bmp)
$gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
$bmp.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png)
$gfx.Dispose()
$bmp.Dispose()
`, outputPath)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("powershell screenshot failed: %s: %w", string(out), err)
}
return nil
}

View file

@ -0,0 +1,171 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/acknowledgment"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
// UpgradeAttestation is the marker the old binary writes immediately before the
// self-upgrade restart. The new binary reads it at startup and attests whether
// the process now running is actually the version the swap installed. Without
// this, a swap that silently failed (or was rolled back from .bak) leaves the
// old binary checking in normally and the server waiting out the full
// stuck-update timeout before anyone notices.
type UpgradeAttestation struct {
CommandID string `json:"command_id"`
FromVersion string `json:"from_version"`
ToVersion string `json:"to_version"`
InitiatedAt time.Time `json:"initiated_at"`
}
// attestationMaxAge bounds marker retries. Past this the server's
// reconcileAgentUpdates sweep has long since timed the command out, so a
// report would only 409; drop the marker instead of retrying forever.
const attestationMaxAge = 24 * time.Hour
func upgradeAttestationPath() string {
return filepath.Join(constants.GetAgentStateDir(), "upgrade-attestation.json")
}
// WriteUpgradeAttestation persists the pre-restart marker. Called by the
// upgrade handler after the binary swap is committed (or handed to the helper)
// and before the service restart.
func WriteUpgradeAttestation(commandID, toVersion string) error {
att := UpgradeAttestation{
CommandID: commandID,
FromVersion: version.Version,
ToVersion: toVersion,
InitiatedAt: time.Now().UTC(),
}
data, err := json.Marshal(att)
if err != nil {
return fmt.Errorf("marshal upgrade attestation: %w", err)
}
if err := os.WriteFile(upgradeAttestationPath(), data, 0o600); err != nil {
return fmt.Errorf("write upgrade attestation: %w", err)
}
return nil
}
// ClearUpgradeAttestation removes the marker. Used on upgrade paths that fail
// before the restart is dispatched — the next boot is not a post-upgrade boot.
func ClearUpgradeAttestation() {
if err := os.Remove(upgradeAttestationPath()); err != nil && !os.IsNotExist(err) {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_remove_failed error=%v", err)
}
}
// RunUpgradeAttestation is the post-upgrade healthcheck, called once at agent
// startup before the polling loop. If a marker exists, the running version is
// checked against the upgrade target:
//
// - running >= target: the swap worked. Local log only — success closure is
// deliberately owned by the server (version check-in confirm in
// ReportMetrics + the reconcileAgentUpdates sweep), not this report.
// - running < target: the swap failed or was rolled back. Report a failed
// update_agent log under the original command_id — the server marks the
// command failed, clears is_updating immediately, and journals a system
// event, instead of the operator waiting out the stuck-update timeout.
func RunUpgradeAttestation(apiClient *client.Client, cfg *config.Config, ackTracker *acknowledgment.Tracker) {
data, err := os.ReadFile(upgradeAttestationPath())
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_read_failed error=%v", err)
}
return
}
var att UpgradeAttestation
if err := json.Unmarshal(data, &att); err != nil {
log.Printf("[WARNING] [agent] [upgrade] attestation_marker_corrupt error=%v", err)
ClearUpgradeAttestation()
return
}
if versionAtLeast(version.Version, att.ToVersion) {
log.Printf("[INFO] [agent] [upgrade] post_upgrade_attestation_ok running=%s target=%s command_id=%s",
version.Version, att.ToVersion, att.CommandID)
ClearUpgradeAttestation()
return
}
log.Printf("[CRITICAL] [agent] [upgrade] post_upgrade_attestation_failed running=%s target=%s from=%s command_id=%s",
version.Version, att.ToVersion, att.FromVersion, att.CommandID)
expired := time.Since(att.InitiatedAt) > attestationMaxAge
report := client.LogReport{
CommandID: att.CommandID,
Action: "update_agent",
Result: "failed",
Stderr: fmt.Sprintf(
"post-upgrade attestation failed: running version %s, expected %s (was %s before the swap) — binary swap failed or was rolled back",
version.Version, att.ToVersion, att.FromVersion),
ExitCode: 1,
Metadata: map[string]string{
"subsystem_label": "Agent Update",
"subsystem": "agent",
"target_version": att.ToVersion,
"running_version": version.Version,
"attested_at": time.Now().UTC().Format(time.RFC3339),
},
}
if err := ReportLogWithAck(apiClient, cfg, ackTracker, report); err != nil {
// 409 = the command is already finalized server-side (timeout sweep or an
// earlier report won the race) — the marker has nothing left to say.
if strings.Contains(err.Error(), "409") {
log.Printf("[INFO] [agent] [upgrade] attestation_already_finalized command_id=%s", att.CommandID)
ClearUpgradeAttestation()
return
}
if expired {
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker expired (>%v), dropping", err, attestationMaxAge)
ClearUpgradeAttestation()
return
}
log.Printf("[ERROR] [agent] [upgrade] attestation_report_failed error=%v — marker retained for retry on next start", err)
return
}
ClearUpgradeAttestation()
}
// versionAtLeast reports whether running >= target, comparing dotted numeric
// versions (leading "v" tolerated). Mirrors the server's IsNewerOrEqualVersion
// check-in confirm: a running version past the target still proves the swap
// took. Non-numeric segments fall back to string comparison.
func versionAtLeast(running, target string) bool {
r := strings.Split(strings.TrimPrefix(running, "v"), ".")
t := strings.Split(strings.TrimPrefix(target, "v"), ".")
for i := 0; i < len(r) || i < len(t); i++ {
var rs, ts string
if i < len(r) {
rs = r[i]
}
if i < len(t) {
ts = t[i]
}
rn, rErr := strconv.Atoi(rs)
tn, tErr := strconv.Atoi(ts)
if rErr != nil || tErr != nil {
if rs == ts {
continue
}
return rs > ts
}
if rn != tn {
return rn > tn
}
}
return true
}

View file

@ -0,0 +1,26 @@
package handlers
import "testing"
func TestVersionAtLeast(t *testing.T) {
cases := []struct {
running, target string
want bool
}{
{"0.2.7.0", "0.2.7.0", true},
{"v0.2.7.0", "0.2.7.0", true},
{"0.2.7.0", "v0.2.7.0", true},
{"0.2.7.1", "0.2.7.0", true}, // past the target still proves the swap
{"0.2.8.0", "0.2.7.9", true},
{"0.2.7.0", "0.2.7.1", false}, // rolled back / swap failed
{"0.2.6.9", "0.2.7.0", false},
{"0.2.10.0", "0.2.9.0", true}, // numeric, not lexicographic
{"0.2.7", "0.2.7.0", false}, // shorter = missing segment treated as lower
{"0.2.7.0", "0.2.7", true},
}
for _, c := range cases {
if got := versionAtLeast(c.running, c.target); got != c.want {
t.Errorf("versionAtLeast(%q, %q) = %v, want %v", c.running, c.target, got, c.want)
}
}
}

View file

@ -0,0 +1,119 @@
package handlers
import (
"log"
"os"
"path/filepath"
"runtime"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
// RunPostUpgradeHealthcheck checks the agent's environment against what this
// version expects. Called once at startup after RunUpgradeAttestation, before
// the polling loop. All findings are warnings — the agent continues regardless.
// Findings are logged and a non-zero return indicates gaps found.
func RunPostUpgradeHealthcheck(cfg *config.Config) int {
gaps := 0
// --- Config key completeness ---
// The install template's default JSON is the source of truth for freshly
// installed agents. Upgraded agents carry over their old config verbatim,
// so new keys added this release may be absent. The agent's merge logic
// (loadFromFile → getDefaultConfig chain) fills zero values on access, but
// visible gaps help the operator understand why defaults are shipping.
check := func(present bool, format string, args ...interface{}) {
if !present {
log.Printf("[WARN] [agent] [healthcheck] "+format, args...)
gaps++
}
}
// Top-level keys the template should have set. The agent.Config struct
// with json tags is the canonical list — these are the non-omitempty keys
// the template writes for fresh installs. We check the config struct
// was populated (not zero-valued per the template JSON) and not missing.
check(cfg.ServerURL != "", "config key 'server_url' is empty — should be set by installer")
check(cfg.CheckInInterval > 0, "config key 'check_in_interval' is zero — should be 300 from template")
// PollingConfig: this version added these keys; warn if empty.
check(cfg.Polling.JitterMaxSeconds > 0 || cfg.Polling.BackoffBaseSeconds > 0,
"config key 'polling' missing — polling resilience tuning will use defaults")
// CommandSigning: recommended on.
check(cfg.CommandSigning.Enabled,
"config key 'command_signing.enabled' is false — recommend true")
// DesktopConfig: warn about missing keys if enabled.
if cfg.Desktop.Enabled {
check(cfg.Desktop.MaxRestarts > 0,
"config key 'desktop.max_restarts' is zero — agent will not auto-restart the desktop app on crash")
}
// --- Binary presence ---
// The helper binary (redflag-helper) should exist when command signing is on.
if cfg.CommandSigning.Enabled {
helperPath := filepath.Join(filepath.Dir(os.Args[0]), "redflag-helper")
if runtime.GOOS == "windows" {
helperPath += ".exe"
}
check(fileExists(helperPath),
"helper binary not found at %s — capability-gated installs will fail", helperPath)
}
// Desktop binary should exist when desktop is enabled.
if cfg.Desktop.Enabled {
desktopPath := filepath.Join(filepath.Dir(os.Args[0]), "redflag-desktop")
if runtime.GOOS == "windows" {
desktopPath += ".exe"
}
check(fileExists(desktopPath),
"desktop binary not found at %s — local operations console will not appear", desktopPath)
}
// --- Autostart entry (Linux only) ---
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
check(fileExists(autostartPath),
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
}
// --- Socket directory permissions ---
// The local API socket lives under GetAgentStateDir()'s parent with 0710.
socketDir := filepath.Join(constants.GetAgentStateDir(), "..", "localapi")
resolved, _ := filepath.EvalSymlinks(socketDir)
if resolved == "" {
resolved = socketDir
}
if info, err := os.Stat(resolved); err == nil {
perm := info.Mode().Perm()
check(perm&0o010 != 0,
"localapi socket dir %s has permissions %#o — the Desktop user cannot traverse to the socket (needs 0o10 group execute)", resolved, perm)
} else {
log.Printf("[WARN] [agent] [healthcheck] localapi socket dir %s not found — Desktop may not work: %v", resolved, err)
gaps++
}
if gaps > 0 {
log.Printf("[WARN] [agent] [healthcheck] complete gaps=%d — re-run the install script or correct manually", gaps)
} else {
log.Printf("[INFO] [agent] [healthcheck] all_ok")
}
return gaps
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// ReportHealthcheckGapsAsEvents sends system event entries for each gap found.
// Called when gaps > 0 after RunPostUpgradeHealthcheck.
// TODO: wire into system event logger once that's available from the handler context.
func ReportHealthcheckGapsAsEvents(gaps int, cfg *config.Config) {
// Placeholder — gaps are logged above.
// A future iteration will emit proper system events via the agent's
// security logger or the server's event logging endpoint.
_ = gaps
_ = cfg
}

View file

@ -0,0 +1,168 @@
package installer
import (
"context"
"fmt"
"log"
"os/exec"
"regexp"
"strings"
"time"
)
// APTInstaller handles APT package installations
type APTInstaller struct{}
// NewAPTInstaller creates a new APT installer
func NewAPTInstaller() *APTInstaller {
return &APTInstaller{}
}
// IsAvailable checks if APT is available on this system
func (i *APTInstaller) IsAvailable() bool {
_, err := exec.LookPath("apt-get")
return err == nil
}
// DryRun performs a dry run installation to check dependencies
func (i *APTInstaller) DryRun(packageName, version string) (*InstallResult, error) {
startTime := time.Now()
runner, err := NewDiscoveryRunner("apt")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] discovery_runner_create error=%w", err)
}
_, updateErr := runner.Run(context.Background(), "update")
if updateErr != nil {
log.Printf("Warning: APT update failed (continuing with dry run): %v", updateErr)
}
target := packageName
if version != "" {
target = packageName + "=" + version
}
result, err := runner.Run(context.Background(), "install", "--dry-run", "--yes", target)
duration := int(time.Since(startTime).Seconds())
// nil guard
if result == nil {
result = &RunResult{}
}
deps := i.parseDependenciesFromAPTOutput(result.Stdout, packageName)
installResult := &InstallResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: duration,
IsDryRun: true,
Action: "dry_run",
}
if err != nil {
if len(deps) > 0 {
installResult.Success = true
installResult.Dependencies = deps
return installResult, nil
}
installResult.Success = false
installResult.ErrorMessage = fmt.Sprintf("APT dry run failed: %v", err)
return installResult, err
}
installResult.Success = true
installResult.Dependencies = deps
return installResult, nil
}
// parseDependenciesFromAPTOutput extracts dependency package names from APT dry run output
func (i *APTInstaller) parseDependenciesFromAPTOutput(output string, packageName string) []string {
var dependencies []string
// Regex patterns to find dependencies in APT output
patterns := []*regexp.Regexp{
// Match "The following additional packages will be installed:" section
regexp.MustCompile(`(?s)The following additional packages will be installed:(.*?)(\n\n|\z)`),
// Match "The following NEW packages will be installed:" section
regexp.MustCompile(`(?s)The following NEW packages will be installed:(.*?)(\n\n|\z)`),
}
for _, pattern := range patterns {
matches := pattern.FindStringSubmatch(output)
if len(matches) > 1 {
// Extract package names from the matched section
packageLines := strings.Split(matches[1], "\n")
for _, line := range packageLines {
line = strings.TrimSpace(line)
// Skip empty lines and section headers
if line != "" && !strings.Contains(line, "will be installed") && !strings.Contains(line, "packages") {
// Extract package names (they're typically space-separated)
packages := strings.Fields(line)
for _, pkg := range packages {
pkg = strings.TrimSpace(pkg)
// Filter out common non-package words
if pkg != "" && !strings.Contains(pkg, "recommended") &&
!strings.Contains(pkg, "suggested") && !strings.Contains(pkg, "following") {
dependencies = append(dependencies, pkg)
}
}
}
}
}
}
// Remove duplicates and filter out the original package
uniqueDeps := make([]string, 0)
seen := make(map[string]bool)
for _, dep := range dependencies {
if dep != packageName && !seen[dep] {
seen[dep] = true
uniqueDeps = append(uniqueDeps, dep)
}
}
return uniqueDeps
}
// GetPackageType returns type of packages this installer handles
func (i *APTInstaller) GetPackageType() string {
return "apt"
}
// getExitCode extracts exit code from exec error
func getExitCode(err error) int {
if err == nil {
return 0
}
if exitError, ok := err.(*exec.ExitError); ok {
return exitError.ExitCode()
}
return 1 // Default error code
}
// VerifyHash checks that the package's current candidate .deb hash matches the
// pinned expected value. The hash is read from the signed apt index (the same
// SHA256 apt itself verifies the download against), so this catches an artifact
// swapped under a fixed version since pinning. apt's own GPG verification of the
// index and package remains the integrity layer at install.
func (i *APTInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
if expectedSHA256 == "" {
return fmt.Errorf("no expected hash registered for package %q — hash verification is mandatory for capability-gated installs", packageName)
}
resolved, err := ResolveArtifactSHA256("apt", packageName, version)
if err != nil {
return fmt.Errorf("failed to resolve package artifact: %w", err)
}
if !strings.EqualFold(resolved.SHA256, expectedSHA256) {
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, resolved.SHA256)
}
log.Printf("[INFO] [agent] [installer] hash_verified package=%s", packageName)
return nil
}

View file

@ -0,0 +1,251 @@
package installer
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
// ResolvedArtifact is one package resolved to the exact artifact a pinned install
// would fetch, with the SHA256 the package manager verifies against its GPG-signed
// repository metadata. It is the unit the server pins and the capability token binds.
type ResolvedArtifact struct {
Name string // package name as the package manager records it
Version string // exact version (dnf: EVR; apt: Version field)
SHA256 string // lowercase hex; canonical artifact hash from signed metadata
}
// packageNamePattern bounds package identifiers to the characters real dnf/apt
// names use. Resolution shells out (no shell, argv only — so this is defense in
// depth, not the sole guard) and the name originates in a server command, so we
// refuse anything that isn't a plausible package reference rather than pass it on.
var packageNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+:~-]*$`)
func validatePackageName(name string) error {
if name == "" {
return fmt.Errorf("empty package name")
}
if len(name) > 256 {
return fmt.Errorf("package name too long")
}
if !packageNamePattern.MatchString(name) {
return fmt.Errorf("package name contains disallowed characters: %q", name)
}
return nil
}
// ResolveArtifactSHA256 resolves the canonical artifact hash for a package on the
// given package manager. When version is non-empty, resolution is for that exact
// target version, not the current candidate. It is the agent-side hash source for
// the registry: the server cannot reach an agent's repos, so the agent reads the
// hash its own signed metadata anchors. Returns an error (never a wrong hash)
// when resolution is not possible for the package type or the package cannot be
// resolved.
func ResolveArtifactSHA256(packageType, packageName, version string) (*ResolvedArtifact, error) {
if err := validatePackageName(packageName); err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_invalid_name type=%s error=%w", packageType, err)
}
if version != "" {
if err := validatePackageName(version); err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_invalid_version type=%s error=%w", packageType, err)
}
}
switch packageType {
case "dnf":
return resolveDNFArtifact(packageName, version)
case "apt":
return resolveAPTArtifact(packageName, version)
default:
return nil, fmt.Errorf("artifact hash resolution not supported for package type %q", packageType)
}
}
// resolveDNFArtifact downloads the package to a private temp dir with `dnf
// download` (which verifies the artifact against the signed primary.xml during
// download), reads its exact NEVRA via rpm, and hashes the file. The download is
// unprivileged and the temp dir is removed afterward.
func resolveDNFArtifact(packageName, version string) (*ResolvedArtifact, error) {
tmpDir, err := os.MkdirTemp("", "redflag-hash-")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_tmpdir error=%w", err)
}
defer os.RemoveAll(tmpDir)
target := packageName
if version != "" {
target = packageName + "-" + version
}
out, err := exec.Command("dnf", "download", "--destdir", tmpDir, target).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_dnf_download pkg=%s error=%w output=%s",
target, err, strings.TrimSpace(string(out)))
}
rpmPath, err := singleRPMInDir(tmpDir)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_dnf_artifact pkg=%s error=%w", packageName, err)
}
sum, err := fileSHA256(rpmPath)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_dnf_hash pkg=%s error=%w", packageName, err)
}
name, version := rpmNameVersion(rpmPath, packageName)
return &ResolvedArtifact{Name: name, Version: version, SHA256: sum}, nil
}
// singleRPMInDir returns the path of the one binary .rpm a `dnf download` of a
// single named package produces. Source rpms are dropped first: dnf5 pulls the
// matching .src.rpm alongside the binary when the source lives in an enabled repo
// (COPR repos commonly do), but the source is never an install artifact, so it
// must not count toward ambiguity. More than one *binary* rpm remaining means the
// request was genuinely ambiguous and we refuse rather than guess the pinned one.
func singleRPMInDir(dir string) (string, error) {
matches, err := filepath.Glob(filepath.Join(dir, "*.rpm"))
if err != nil {
return "", err
}
binaries := matches[:0]
for _, m := range matches {
if strings.HasSuffix(m, ".src.rpm") {
continue
}
binaries = append(binaries, m)
}
switch len(binaries) {
case 0:
return "", fmt.Errorf("no binary rpm downloaded")
case 1:
return binaries[0], nil
default:
return "", fmt.Errorf("ambiguous download: %d binary rpms produced", len(binaries))
}
}
// rpmNameVersion reads the exact NAME and EVR from a downloaded rpm header.
// It formats EVR the same way dnf check-update does: epoch is included only when
// non-zero. Falls back to the requested name and an empty version if rpm is
// unavailable — the hash is still authoritative, only the recorded version label
// degrades.
func rpmNameVersion(rpmPath, requestedName string) (name, version string) {
out, err := exec.Command("rpm", "-qp", "--nosignature", "--queryformat", "%{NAME}|%{EPOCHNUM}|%{VERSION}|%{RELEASE}", rpmPath).Output()
if err != nil {
return requestedName, ""
}
parts := strings.Split(strings.TrimSpace(string(out)), "|")
if len(parts) != 4 || parts[0] == "" {
return requestedName, ""
}
return parts[0], formatRPMVersion(parts[1], parts[2], parts[3])
}
func formatRPMVersion(epoch, version, release string) string {
epoch = strings.TrimSpace(epoch)
version = strings.TrimSpace(version)
release = strings.TrimSpace(release)
if version == "" {
return ""
}
evr := version
if release != "" {
evr += "-" + release
}
if epoch != "" && epoch != "0" && epoch != "(none)" {
evr = epoch + ":" + evr
}
return evr
}
// resolveAPTArtifact reads the target version's .deb SHA256 straight from the
// signed apt index via apt-cache. With no explicit version, it uses the current
// candidate. No download is needed: apt already exposes the hash the
// Release/Packages index commits to.
func resolveAPTArtifact(packageName, version string) (*ResolvedArtifact, error) {
candidate := version
if candidate == "" {
var err error
candidate, err = aptCandidateVersion(packageName)
if err != nil {
return nil, err
}
}
out, err := exec.Command("apt-cache", "show", packageName+"="+candidate).Output()
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_show pkg=%s version=%s error=%w",
packageName, candidate, err)
}
sum := aptFieldFromStanza(string(out), "SHA256:")
if sum == "" {
return nil, fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_no_sha256 pkg=%s version=%s", packageName, candidate)
}
return &ResolvedArtifact{Name: packageName, Version: candidate, SHA256: strings.ToLower(sum)}, nil
}
// aptCandidateVersion returns the version apt would install for the package — the
// "Candidate:" line of apt-cache policy. That is the exact version a pinned
// install targets.
func aptCandidateVersion(packageName string) (string, error) {
out, err := exec.Command("apt-cache", "policy", packageName).Output()
if err != nil {
return "", fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_policy pkg=%s error=%w", packageName, err)
}
v := parseAptCandidate(string(out))
if v == "" {
return "", fmt.Errorf("[ERROR] [agent] [installer] hash_resolve_apt_no_candidate pkg=%s", packageName)
}
return v, nil
}
// parseAptCandidate extracts the installable version from `apt-cache policy`
// output ("Candidate:" line). Returns "" when absent or "(none)".
func parseAptCandidate(output string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Candidate:") {
v := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
if v == "(none)" {
return ""
}
return v
}
}
return ""
}
// aptFieldFromStanza returns the value of the first matching field prefix in an
// apt-cache show stanza (e.g. "SHA256:").
func aptFieldFromStanza(output, prefix string) string {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
}
// fileSHA256 returns the lowercase hex SHA256 of a file, streaming.
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, f); err != nil {
return "", err
}
return hex.EncodeToString(hasher.Sum(nil)), nil
}

View file

@ -0,0 +1,134 @@
package installer
import "testing"
func TestValidatePackageName(t *testing.T) {
valid := []string{
"bash",
"kernel-core",
"glibc-common",
"python3.12",
"lib64gcc-s1",
"foo+bar",
"3:firefox", // epoch-prefixed reference
"gcc~snapshot",
}
for _, name := range valid {
if err := validatePackageName(name); err != nil {
t.Errorf("validatePackageName(%q) = %v, want nil", name, err)
}
}
invalid := []string{
"",
"-leadingdash",
"semi;colon",
"pipe|inject",
"space here",
"back`tick",
"new\nline",
"$(cmd)",
}
for _, name := range invalid {
if err := validatePackageName(name); err == nil {
t.Errorf("validatePackageName(%q) = nil, want error", name)
}
}
}
func TestFormatRPMVersion(t *testing.T) {
tests := []struct {
name string
epoch string
version string
release string
want string
}{
{
name: "no epoch",
epoch: "0",
version: "3.4.8",
release: "1.fc43",
want: "3.4.8-1.fc43",
},
{
name: "epoch",
epoch: "3",
version: "29.5.2",
release: "1.fc43",
want: "3:29.5.2-1.fc43",
},
{
name: "none epoch",
epoch: "(none)",
version: "8.15.0",
release: "7.fc43",
want: "8.15.0-7.fc43",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatRPMVersion(tt.epoch, tt.version, tt.release); got != tt.want {
t.Fatalf("formatRPMVersion(%q, %q, %q) = %q, want %q",
tt.epoch, tt.version, tt.release, got, tt.want)
}
})
}
}
func TestParseAptCandidate(t *testing.T) {
cases := []struct {
name string
output string
want string
}{
{
name: "normal candidate",
output: `nginx:
Installed: 1.18.0-6ubuntu14.4
Candidate: 1.18.0-6ubuntu14.5
Version table:
1.18.0-6ubuntu14.5 500
500 http://archive.ubuntu.com/ubuntu jammy-updates/main amd64 Packages`,
want: "1.18.0-6ubuntu14.5",
},
{
name: "no candidate",
output: `bogus:
Installed: (none)
Candidate: (none)
Version table:`,
want: "",
},
{
name: "missing line",
output: "some unrelated output\n",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseAptCandidate(tc.output); got != tc.want {
t.Errorf("parseAptCandidate() = %q, want %q", got, tc.want)
}
})
}
}
func TestAptFieldFromStanza(t *testing.T) {
stanza := `Package: nginx
Version: 1.18.0-6ubuntu14.5
Architecture: amd64
SHA256: 9f7c1e2b3a4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7e8f90112233445
Filename: pool/main/n/nginx/nginx_1.18.0-6ubuntu14.5_amd64.deb`
if got := aptFieldFromStanza(stanza, "SHA256:"); got != "9f7c1e2b3a4d5e6f7081920a1b2c3d4e5f60718293a4b5c6d7e8f90112233445" {
t.Errorf("aptFieldFromStanza(SHA256) = %q", got)
}
if got := aptFieldFromStanza(stanza, "Version:"); got != "1.18.0-6ubuntu14.5" {
t.Errorf("aptFieldFromStanza(Version) = %q", got)
}
if got := aptFieldFromStanza(stanza, "MD5sum:"); got != "" {
t.Errorf("aptFieldFromStanza(missing) = %q, want empty", got)
}
}

View file

@ -0,0 +1,176 @@
// discovery.go — single chokepoint for all read-only package-manager operations.
// Every scan, dry-run, and hash-resolve goes through here. Discovery runs
// in the agent's sandbox; the runner owns sandbox compatibility (temp logdir
// for dnf, etc.) per ecosystem.
package installer
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// EcosystemConfig describes one package manager's discovery behaviour.
type EcosystemConfig struct {
Binary string
SudoForDiscovery bool
// SandboxOpts returns extra args to insert before the subcommand
// so the package manager can write incidental logs/cache inside
// a ProtectSystem=strict systemd unit. tmpDir is a private temp
// dir created by the runner; it is cleaned up after the command.
SandboxOpts func(tmpDir string) []string
}
// ecosystemRegistry is the canonical set of known ecosystems.
// Adding a new ecosystem (AUR, Snap, Flatpak, Homebrew) means one entry here.
var ecosystemRegistry = map[string]EcosystemConfig{
"dnf": {
Binary: "dnf",
// Discovery is unprivileged. SandboxOpts redirect dnf's log and cache
// into an agent-writable temp dir, so check-update / makecache /
// install --downloadonly / download all run without root. Mutation is
// the helper's job (root via systemd-run); the agent holds no dnf sudo.
SudoForDiscovery: false,
SandboxOpts: func(tmpDir string) []string {
return []string{
"--setopt=logdir=" + tmpDir,
"--setopt=cachedir=" + tmpDir,
}
},
},
"apt": {
Binary: "apt",
// Discovery is unprivileged, same model as dnf. SandboxOpts redirect apt's
// lists/cache/state/log into an agent-writable temp dir so `apt update`,
// `apt list --upgradable`, and `apt install --dry-run` run without root.
// Mutation is the helper's job; the agent holds no apt sudo. Cost: each
// scan re-fetches repo metadata into the ephemeral dir (perf, not
// correctness), mirroring dnf's tradeoff.
SudoForDiscovery: false,
SandboxOpts: func(tmpDir string) []string {
return []string{
"-o", "Dir::State::Lists=" + tmpDir + "/lists",
"-o", "Dir::Cache=" + tmpDir + "/cache",
"-o", "Dir::State=" + tmpDir + "/state",
"-o", "Dir::Log=" + tmpDir,
}
},
},
"docker": {
Binary: "docker",
SudoForDiscovery: false,
},
"pacman": {
Binary: "checkupdates",
SudoForDiscovery: false,
// checkupdates handles its own sandboxing internally — it syncs a
// private copy of the repo databases into a temp dir and runs
// pacman -Qu against it. No root required, no mutation of the live
// pacman database. Discovery is purely read-only.
},
"winget": {
Binary: "winget",
SudoForDiscovery: false,
},
"windows_update": {
Binary: "powershell",
SudoForDiscovery: false,
},
}
// ConfigFor returns the EcosystemConfig for a package type.
func ConfigFor(packageType string) (EcosystemConfig, error) {
cfg, ok := ecosystemRegistry[packageType]
if !ok {
return EcosystemConfig{}, fmt.Errorf("unknown ecosystem: %q", packageType)
}
return cfg, nil
}
// DiscoveryRunner executes read-only package-manager commands inside the
// agent's sandbox.
type DiscoveryRunner struct {
cfg EcosystemConfig
}
// NewDiscoveryRunner returns a runner for the given ecosystem.
func NewDiscoveryRunner(packageType string) (*DiscoveryRunner, error) {
cfg, err := ConfigFor(packageType)
if err != nil {
return nil, err
}
return &DiscoveryRunner{cfg: cfg}, nil
}
// RunResult is the structured output of a discovery command.
type RunResult struct {
Stdout string
Stderr string
ExitCode int
Duration time.Duration
}
// Run executes a discovery command. args is the subcommand and its arguments.
// SandboxOpts are prepended if set; sudo is used if SudoForDiscovery is true.
func (r *DiscoveryRunner) Run(ctx context.Context, args ...string) (*RunResult, error) {
start := time.Now()
if len(args) == 0 {
return nil, fmt.Errorf("[ERROR] [agent] [discovery] no_args ecosystem=%s", r.cfg.Binary)
}
var tmpDir string
var fullArgs []string
if r.cfg.SandboxOpts != nil {
var err error
tmpDir, err = os.MkdirTemp("", "redflag-discovery-")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [discovery] tmpdir_create ecosystem=%s error=%w", r.cfg.Binary, err)
}
defer os.RemoveAll(tmpDir)
fullArgs = append(fullArgs, r.cfg.SandboxOpts(tmpDir)...)
}
fullArgs = append(fullArgs, args...)
var cmd *exec.Cmd
if r.cfg.SudoForDiscovery {
fullPath, err := exec.LookPath(r.cfg.Binary)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [discovery] binary_not_found ecosystem=%s binary=%s error=%w",
r.cfg.Binary, r.cfg.Binary, err)
}
sudoArgs := append([]string{fullPath}, fullArgs...)
cmd = exec.CommandContext(ctx, "sudo", sudoArgs...)
} else {
cmd = exec.CommandContext(ctx, r.cfg.Binary, fullArgs...)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
dur := time.Since(start)
result := &RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
Duration: dur,
}
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = -1
}
return result, fmt.Errorf("[ERROR] [agent] [discovery] command_failed ecosystem=%s args=%v exit=%d stderr=%s error=%w",
r.cfg.Binary, args, result.ExitCode, strings.TrimSpace(result.Stderr), runErr)
}
return result, nil
}

View file

@ -0,0 +1,67 @@
package installer
import (
"strings"
"testing"
)
func TestConfigForKnownEcosystems(t *testing.T) {
for _, eco := range []string{"dnf", "apt", "docker", "winget", "windows_update"} {
cfg, err := ConfigFor(eco)
if err != nil {
t.Errorf("ConfigFor(%q): unexpected error: %v", eco, err)
}
if cfg.Binary == "" {
t.Errorf("ConfigFor(%q): empty Binary", eco)
}
}
}
func TestConfigForUnknownEcosystem(t *testing.T) {
_, err := ConfigFor("nonexistent")
if err == nil {
t.Error("ConfigFor(nonexistent): expected error, got nil")
}
}
func TestDNFConfigSandboxOpts(t *testing.T) {
cfg, _ := ConfigFor("dnf")
if cfg.SandboxOpts == nil {
t.Fatal("dnf must have SandboxOpts")
}
opts := cfg.SandboxOpts("/tmp/test")
if len(opts) != 2 {
t.Fatalf("expected 2 opts, got %d: %v", len(opts), opts)
}
if !strings.HasPrefix(opts[0], "--setopt=logdir=") {
t.Errorf("expected --setopt=logdir=..., got %s", opts[0])
}
if !strings.HasPrefix(opts[1], "--setopt=cachedir=") {
t.Errorf("expected --setopt=cachedir=..., got %s", opts[1])
}
}
func TestAPTConfigSandboxOpts(t *testing.T) {
cfg, _ := ConfigFor("apt")
if cfg.SandboxOpts == nil {
t.Fatal("apt must have SandboxOpts")
}
opts := cfg.SandboxOpts("/tmp/test")
// APT sandbox opts: -o Dir::State::Lists=... -o Dir::Cache=... etc.
if len(opts) < 2 {
t.Fatalf("expected at least 2 opts, got %d: %v", len(opts), opts)
}
if opts[0] != "-o" {
t.Errorf("expected first opt to be -o, got %s", opts[0])
}
if !strings.HasPrefix(opts[1], "Dir::") {
t.Errorf("expected Dir:: prefix, got %s", opts[1])
}
}
func TestNewDiscoveryRunnerUnknown(t *testing.T) {
_, err := NewDiscoveryRunner("auraura")
if err == nil {
t.Error("expected error for unknown ecosystem")
}
}

View file

@ -0,0 +1,240 @@
package installer
import (
"context"
"fmt"
"log"
"os/exec"
"regexp"
"strings"
"time"
)
// HashVerifier checks package hashes against expected values
type HashVerifier struct {
serverURL string
}
// NewHashVerifier creates a new hash verifier
func NewHashVerifier(serverURL string) *HashVerifier {
return &HashVerifier{
serverURL: serverURL,
}
}
// VerifyPackageHash checks that the package's current canonical artifact hash
// matches the pinned expected value. The hash is re-resolved locally from the
// agent's signed repo metadata (the same source the server pinned), so this
// catches an artifact swapped under a fixed version since pinning. dnf cannot be
// fetched from the server (the server has no access to the agent's repos), so
// verification is agent-local; RPM's own GPG check remains as a second layer at
// install.
func (h *HashVerifier) VerifyPackageHash(packageName, version, expectedSHA256 string) error {
if expectedSHA256 == "" {
return fmt.Errorf("no expected hash registered for package %q — hash verification is mandatory for capability-gated installs", packageName)
}
resolved, err := ResolveArtifactSHA256("dnf", packageName, version)
if err != nil {
return fmt.Errorf("failed to resolve package artifact: %w", err)
}
if !strings.EqualFold(resolved.SHA256, expectedSHA256) {
return fmt.Errorf("package hash mismatch: expected %s, got %s", expectedSHA256, resolved.SHA256)
}
return nil
}
// DNFInstaller handles DNF package installations
type DNFInstaller struct {
hashVerifier *HashVerifier
expectedSHA256 string
}
// NewDNFInstaller creates a new DNF installer with hash verification
func NewDNFInstaller(serverURL string) *DNFInstaller {
return &DNFInstaller{
hashVerifier: NewHashVerifier(serverURL),
expectedSHA256: "",
}
}
// IsAvailable checks if DNF is available on this system
func (i *DNFInstaller) IsAvailable() bool {
_, err := exec.LookPath("dnf")
return err == nil
}
// DryRun performs a dry run installation to check dependencies
func (i *DNFInstaller) DryRun(packageName, version string) (*InstallResult, error) {
startTime := time.Now()
runner, err := NewDiscoveryRunner("dnf")
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] discovery_runner_create error=%w", err)
}
// makecache — best effort, don't fail if it doesn't work
if _, refreshErr := runner.Run(context.Background(), "makecache"); refreshErr != nil {
log.Printf("Warning: DNF makecache failed (continuing with dry run): %v", refreshErr)
}
// NEVRA: packageName-version resolves the specific upgrade, not "is it installed?"
target := packageName
if version != "" {
target = packageName + "-" + version
}
result, err := runner.Run(context.Background(), "install", "--assumeno", "--downloadonly", target)
duration := int(time.Since(startTime).Seconds())
if result == nil {
result = &RunResult{}
}
deps := i.parseDependenciesFromDNFOutput(result.Stdout, packageName)
installResult := &InstallResult{
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
DurationSeconds: duration,
IsDryRun: true,
Action: "dry_run",
}
// --assumeno cancels the transaction, so DNF exits non-zero on a dry run
// that resolved successfully — a non-zero exit is not by itself a failure.
// But a non-empty stdout is not success either: "No match for argument",
// "Nothing to do", and "Error:" all print output and exit non-zero. Success
// means DNF actually resolved a transaction that would change packages,
// marked by a "Transaction Summary" block (which never coexists with
// "Nothing to do"). Gate on that, whether or not there are extra deps.
if dnfTransactionResolved(result.Stdout) {
installResult.Success = true
installResult.Dependencies = deps
return installResult, nil
}
installResult.Success = false
if err != nil {
installResult.ErrorMessage = fmt.Sprintf("DNF dry run did not resolve a transaction: %v", err)
return installResult, err
}
installResult.ErrorMessage = "DNF dry run did not resolve an installable transaction"
return installResult, fmt.Errorf("[ERROR] [agent] [installer] dnf_dry_run_no_transaction package=%s", target)
}
// dnfTransactionResolved reports whether DNF dry-run output describes a
// transaction that would actually change packages. "Transaction Summary" is
// printed only when at least one package will be installed/upgraded/removed and
// never appears alongside "Nothing to do", so it is the reliable success signal
// — distinct from "got output," which failed resolutions also produce.
func dnfTransactionResolved(output string) bool {
if strings.Contains(output, "Nothing to do") {
return false
}
return strings.Contains(output, "Transaction Summary")
}
// parseDependenciesFromDNFOutput extracts dependency package names from DNF dry run output
func (i *DNFInstaller) parseDependenciesFromDNFOutput(output string, packageName string) []string {
var dependencies []string
// Regex patterns to find dependencies in DNF output
patterns := []*regexp.Regexp{
// Match "Installing dependencies:" section
regexp.MustCompile(`(?s)Installing dependencies:(.*?)(\n\n|\z|Transaction Summary:)`),
// Match "Dependencies resolved." section and package list
regexp.MustCompile(`(?s)Dependencies resolved\.(.*?)(\n\n|\z|Transaction Summary:)`),
// Match package installation lines
regexp.MustCompile(`^\s*([a-zA-Z0-9][a-zA-Z0-9+._-]*)\s+[a-zA-Z0-9:.]+(?:\s+[a-zA-Z]+)?$`),
}
for _, pattern := range patterns {
if strings.Contains(pattern.String(), "Installing dependencies:") {
matches := pattern.FindStringSubmatch(output)
if len(matches) > 1 {
// Extract package names from the dependencies section
lines := strings.Split(matches[1], "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && !strings.Contains(line, "Dependencies") {
pkg := i.extractPackageNameFromDNFLine(line)
if pkg != "" {
dependencies = append(dependencies, pkg)
}
}
}
}
}
}
// Also look for transaction summary which lists all packages to be installed
transactionPattern := regexp.MustCompile(`(?s)Transaction Summary:\s*\n\s*Install\s+(\d+) Packages?\s*\n((?:\s+\d+\s+[a-zA-Z0-9+._-]+\s+[a-zA-Z0-9:.]+.*\n?)*)`)
matches := transactionPattern.FindStringSubmatch(output)
if len(matches) > 2 {
installLines := strings.Split(matches[2], "\n")
for _, line := range installLines {
line = strings.TrimSpace(line)
if line != "" {
pkg := i.extractPackageNameFromDNFLine(line)
if pkg != "" && pkg != packageName {
dependencies = append(dependencies, pkg)
}
}
}
}
// Remove duplicates
uniqueDeps := make([]string, 0)
seen := make(map[string]bool)
for _, dep := range dependencies {
if dep != packageName && !seen[dep] {
seen[dep] = true
uniqueDeps = append(uniqueDeps, dep)
}
}
return uniqueDeps
}
// extractPackageNameFromDNFLine extracts package name from a DNF output line
func (i *DNFInstaller) extractPackageNameFromDNFLine(line string) string {
// Remove architecture info if present
if idx := strings.LastIndex(line, "."); idx > 0 {
archSuffix := line[idx:]
if strings.Contains(archSuffix, ".x86_64") || strings.Contains(archSuffix, ".noarch") ||
strings.Contains(archSuffix, ".i386") || strings.Contains(archSuffix, ".arm64") {
line = line[:idx]
}
}
// Extract package name (typically at the start of the line)
fields := strings.Fields(line)
if len(fields) > 0 {
pkg := fields[0]
// Remove version info if present
if idx := strings.Index(pkg, "-"); idx > 0 {
potentialName := pkg[:idx]
// Check if this looks like a version (contains numbers)
versionPart := pkg[idx+1:]
if strings.Contains(versionPart, ".") || regexp.MustCompile(`\d`).MatchString(versionPart) {
return potentialName
}
}
return pkg
}
return ""
}
// GetPackageType returns type of packages this installer handles
func (i *DNFInstaller) GetPackageType() string {
return "dnf"
}
// VerifyHash verifies the package hash before installation
func (i *DNFInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
return i.hashVerifier.VerifyPackageHash(packageName, version, expectedSHA256)
}

View file

@ -0,0 +1,205 @@
package installer
import (
"fmt"
"os/exec"
"strings"
"time"
)
// DockerInstaller handles Docker image updates
type DockerInstaller struct{}
// NewDockerInstaller creates a new Docker installer
func NewDockerInstaller() (*DockerInstaller, error) {
// Check if docker is available first
if _, err := exec.LookPath("docker"); err != nil {
return nil, fmt.Errorf("docker not found")
}
return &DockerInstaller{}, nil
}
// IsAvailable checks if Docker is available on this system
func (i *DockerInstaller) IsAvailable() bool {
_, err := exec.LookPath("docker")
return err == nil
}
// Update pulls a new image using docker CLI
func (i *DockerInstaller) Update(imageName, targetVersion string) (*InstallResult, error) {
startTime := time.Now()
// Pull the new image
fmt.Printf("Pulling Docker image: %s...\n", imageName)
pullCmd := exec.Command("docker", "pull", imageName)
output, err := pullCmd.CombinedOutput()
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to pull Docker image: %v\nStdout: %s", err, string(output)),
Stdout: string(output),
Stderr: "",
ExitCode: getExitCode(err),
DurationSeconds: int(time.Since(startTime).Seconds()),
Action: "pull",
}, fmt.Errorf("docker pull failed: %w", err)
}
fmt.Printf("Successfully pulled image: %s\n", string(output))
duration := int(time.Since(startTime).Seconds())
return &InstallResult{
Success: true,
Stdout: string(output),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
Action: "pull",
ContainersUpdated: []string{}, // Would find and recreate containers in a real implementation
}, nil
}
// UpdatePackage updates a specific Docker image (alias for Update method)
func (i *DockerInstaller) UpdatePackage(imageName string) (*InstallResult, error) {
// Docker uses same logic for updating as installing
return i.Update(imageName, "")
}
// Install installs a Docker image (alias for Update)
func (i *DockerInstaller) Install(imageName string) (*InstallResult, error) {
return i.Update(imageName, "")
}
// InstallMultiple installs multiple Docker images
func (i *DockerInstaller) InstallMultiple(imageNames []string) (*InstallResult, error) {
if len(imageNames) == 0 {
return &InstallResult{
Success: false,
ErrorMessage: "No images specified for installation",
}, fmt.Errorf("no images specified")
}
startTime := time.Now()
var allOutput strings.Builder
var errors []string
for _, imageName := range imageNames {
fmt.Printf("Pulling Docker image: %s...\n", imageName)
pullCmd := exec.Command("docker", "pull", imageName)
output, err := pullCmd.CombinedOutput()
allOutput.WriteString(string(output))
if err != nil {
errors = append(errors, fmt.Sprintf("Failed to pull %s: %v", imageName, err))
} else {
fmt.Printf("Successfully pulled image: %s\n", imageName)
}
}
duration := int(time.Since(startTime).Seconds())
if len(errors) > 0 {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Docker pull errors: %v", strings.Join(errors, "; ")),
Stdout: allOutput.String(),
Stderr: "",
ExitCode: 1,
DurationSeconds: duration,
Action: "pull_multiple",
}, fmt.Errorf("docker pull failed for some images")
}
return &InstallResult{
Success: true,
Stdout: allOutput.String(),
Stderr: "",
ExitCode: 0,
DurationSeconds: duration,
Action: "pull_multiple",
ContainersUpdated: imageNames,
}, nil
}
// Upgrade is not applicable for Docker in the same way
func (i *DockerInstaller) Upgrade() (*InstallResult, error) {
return &InstallResult{
Success: false,
ErrorMessage: "Docker upgrade not implemented - use specific image updates",
ExitCode: 1,
DurationSeconds: 0,
Action: "upgrade",
}, fmt.Errorf("docker upgrade not implemented")
}
// DryRun for Docker images checks if the image can be pulled without actually pulling it
func (i *DockerInstaller) DryRun(imageName, version string) (*InstallResult, error) {
startTime := time.Now()
// Check if image exists locally
inspectCmd := exec.Command("docker", "image", "inspect", imageName)
output, err := inspectCmd.CombinedOutput()
if err == nil {
// Image exists locally
duration := int(time.Since(startTime).Seconds())
return &InstallResult{
Success: true,
Stdout: fmt.Sprintf("Docker image %s is already available locally", imageName),
Stderr: string(output),
ExitCode: 0,
DurationSeconds: duration,
Dependencies: []string{}, // Docker doesn't have traditional dependencies
IsDryRun: true,
Action: "dry_run",
}, nil
}
// Image doesn't exist locally, check if it exists in registry
// Use docker manifest command to check remote availability
manifestCmd := exec.Command("docker", "manifest", "inspect", imageName)
manifestOutput, manifestErr := manifestCmd.CombinedOutput()
duration := int(time.Since(startTime).Seconds())
if manifestErr != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Docker image %s not found locally or in remote registry", imageName),
Stdout: string(output),
Stderr: string(manifestOutput),
ExitCode: getExitCode(manifestErr),
DurationSeconds: duration,
Dependencies: []string{},
IsDryRun: true,
Action: "dry_run",
}, fmt.Errorf("docker image not found")
}
return &InstallResult{
Success: true,
Stdout: fmt.Sprintf("Docker image %s is available for download", imageName),
Stderr: string(manifestOutput),
ExitCode: 0,
DurationSeconds: duration,
Dependencies: []string{}, // Docker doesn't have traditional dependencies
IsDryRun: true,
Action: "dry_run",
}, nil
}
// GetPackageType returns type of packages this installer handles
func (i *DockerInstaller) GetPackageType() string {
return "docker_image"
}
// VerifyHash verifies the Docker image hash before pulling
func (i *DockerInstaller) VerifyHash(imageName, version, expectedSHA256 string) error {
if expectedSHA256 == "" {
return fmt.Errorf("no expected hash registered for image %q — hash verification is mandatory for capability-gated installs", imageName)
}
// TODO: Docker images should use registry digest verification (Docker Content Trust).
// Until implemented, fail closed — returning an error blocks the install.
return fmt.Errorf("hash verification not implemented for docker image %q — expected=%s", imageName, expectedSHA256)
}

View file

@ -0,0 +1,51 @@
package installer
import "fmt"
// Installer is the agent-side interface for package ecosystems.
// Discovery methods only. Mutation (install, upgrade) goes through
// the capability-token path (supplychain.Consumer → redflag-helper),
// not through this interface.
type Installer interface {
IsAvailable() bool
GetPackageType() string
DryRun(packageName, version string) (*InstallResult, error)
VerifyHash(packageName, version, expectedSHA256 string) error
}
// NonGatedInstaller is the set of ecosystems still permitted to mutate
// directly from the agent (winget, docker, windows_update). dnf/apt
// deliberately do NOT implement these methods — their mutation flows only
// through the capability-token path (supplychain.Consumer → redflag-helper).
//
// This interface is the ledger of "what may still bypass the gate." As
// ecosystems move behind the capability gate, drop them from this set and
// the type assertions in the handlers begin failing for them automatically —
// the boundary is structural, not a guard someone has to remember to add.
type NonGatedInstaller interface {
UpdatePackage(packageName string) (*InstallResult, error)
Upgrade() (*InstallResult, error)
InstallMultiple(packageNames []string) (*InstallResult, error)
}
// InstallerFactory creates appropriate installer based on package type
func InstallerFactory(packageType string, serverURL string) (Installer, error) {
switch packageType {
case "apt":
return NewAPTInstaller(), nil
case "dnf":
return NewDNFInstaller(serverURL), nil
case "docker_image":
installer, err := NewDockerInstaller()
if err != nil {
return nil, fmt.Errorf("docker installer failed: %w", err)
}
return installer, nil
case "windows_update":
return NewWindowsUpdateInstaller(), nil
case "winget":
return NewWingetInstaller(), nil
default:
return nil, fmt.Errorf("unsupported package type: %s", packageType)
}
}

View file

@ -0,0 +1,21 @@
package installer
// PacmanResolvedArtifact is one exact archive in the transaction pacman
// resolved for a requested package. Paths remain valid until Cleanup is called.
type PacmanResolvedArtifact struct {
Name string
Version string
Repository string
ArchivePath string
ArchiveSHA256 string
SignaturePath string
SignatureSHA256 string
}
// PacmanResolution owns the private sync database and cache backing a resolved
// transaction. The caller keeps it alive through helper execution, then calls
// Cleanup even when mint or execution refuses.
type PacmanResolution struct {
Artifacts []PacmanResolvedArtifact
Cleanup func()
}

View file

@ -0,0 +1,216 @@
//go:build linux
package installer
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
const pacmanCommandPath = "/usr/bin/pacman"
var pacmanResolverEnv = []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"LC_ALL=C",
"LANG=C",
}
func pacmanResolverCommand(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...)
cmd.Env = pacmanResolverEnv
output, err := cmd.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if len(message) > 4096 {
message = message[:4096]
}
return nil, fmt.Errorf("%s failed: %w: %s", filepath.Base(name), err, message)
}
return output, nil
}
func parsePacmanPrint(output string) (map[string]string, error) {
repositories := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
fields := strings.Split(line, "|")
if len(fields) != 3 || fields[0] == "" || fields[1] == "" || fields[2] == "" {
return nil, fmt.Errorf("pacman printed malformed closure identity %q", line)
}
repositories[fields[0]+"@"+fields[1]] = fields[2]
}
if len(repositories) == 0 {
return nil, fmt.Errorf("pacman resolved an empty transaction")
}
return repositories, nil
}
func inspectPacmanArchive(path string) (string, string, error) {
output, err := pacmanResolverCommand(pacmanCommandPath, "-Qp", "--", path)
if err != nil {
return "", "", err
}
fields := strings.Fields(string(output))
if len(fields) != 2 || fields[0] == "" || fields[1] == "" {
return "", "", fmt.Errorf("pacman printed malformed archive identity for %s", filepath.Base(path))
}
return fields[0], fields[1], nil
}
func regularPacmanArtifact(path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("not a regular file")
}
return nil
}
// ResolvePacmanClosure refreshes a private sync database, resolves the exact
// transaction, and downloads its archives plus detached signatures into an
// Agent-owned cache. It never mutates the host pacman database or package set.
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
if err := validatePackageName(packageName); err != nil {
return nil, err
}
if version != "" {
if err := validatePackageName(version); err != nil {
return nil, err
}
}
if _, err := os.Stat(pacmanCommandPath); err != nil {
return nil, fmt.Errorf("pacman is unavailable: %w", err)
}
if _, err := exec.LookPath("fakeroot"); err != nil {
return nil, fmt.Errorf("fakeroot is required for private pacman resolution: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
resolutionRoot := filepath.Join(agentDir, "pacman-resolve")
if err := os.MkdirAll(resolutionRoot, 0o700); err != nil {
return nil, fmt.Errorf("create pacman resolution root: %w", err)
}
root, err := os.MkdirTemp(resolutionRoot, "operation-")
if err != nil {
return nil, fmt.Errorf("create pacman resolution: %w", err)
}
cleanup := func() { _ = os.RemoveAll(root) }
fail := func(err error) (*PacmanResolution, error) {
cleanup()
return nil, err
}
database := filepath.Join(root, "db")
cache := filepath.Join(root, "cache")
if err := os.Mkdir(database, 0o700); err != nil {
return fail(fmt.Errorf("create pacman database: %w", err))
}
if err := os.Mkdir(cache, 0o700); err != nil {
return fail(fmt.Errorf("create pacman cache: %w", err))
}
if err := os.Symlink("/var/lib/pacman/local", filepath.Join(database, "local")); err != nil {
return fail(fmt.Errorf("bind installed pacman state: %w", err))
}
fakeroot, err := exec.LookPath("fakeroot")
if err != nil {
return fail(err)
}
if _, err := pacmanResolverCommand(
fakeroot, "--", pacmanCommandPath, "-Sy", "--noconfirm",
"--disable-sandbox-filesystem", "--dbpath", database, "--logfile", "/dev/null",
); err != nil {
return fail(fmt.Errorf("refresh private pacman metadata: %w", err))
}
target := packageName
if version != "" {
target += "=" + version
}
printOutput, err := pacmanResolverCommand(
pacmanCommandPath, "-Sp", "--print-format", "%n|%v|%r",
"--dbpath", database, "--", target,
)
if err != nil {
return fail(fmt.Errorf("resolve pacman transaction: %w", err))
}
repositories, err := parsePacmanPrint(string(printOutput))
if err != nil {
return fail(err)
}
if _, err := pacmanResolverCommand(
fakeroot, "--", pacmanCommandPath, "-Sw", "--noconfirm", "--needed",
"--disable-sandbox-filesystem", "--dbpath", database, "--cachedir", cache,
"--logfile", "/dev/null", "--", target,
); err != nil {
return fail(fmt.Errorf("download pacman transaction: %w", err))
}
entries, err := os.ReadDir(cache)
if err != nil {
return fail(fmt.Errorf("read pacman cache: %w", err))
}
artifacts := make([]PacmanResolvedArtifact, 0, len(entries)/2)
rootFound := false
for _, entry := range entries {
name := entry.Name()
if !strings.Contains(name, ".pkg.tar.") || strings.HasSuffix(name, ".sig") || strings.HasSuffix(name, ".part") {
continue
}
archivePath := filepath.Join(cache, name)
if err := regularPacmanArtifact(archivePath); err != nil {
return fail(fmt.Errorf("unsafe pacman archive %s: %w", name, err))
}
signaturePath := archivePath + ".sig"
if err := regularPacmanArtifact(signaturePath); err != nil {
return fail(fmt.Errorf("pacman archive %s has no regular detached signature: %w", name, err))
}
resolvedName, resolvedVersion, err := inspectPacmanArchive(archivePath)
if err != nil {
return fail(err)
}
repository, ok := repositories[resolvedName+"@"+resolvedVersion]
if !ok {
return fail(fmt.Errorf("pacman repository missing for %s@%s", resolvedName, resolvedVersion))
}
archiveHash, err := fileSHA256(archivePath)
if err != nil {
return fail(fmt.Errorf("hash pacman archive %s: %w", name, err))
}
signatureHash, err := fileSHA256(signaturePath)
if err != nil {
return fail(fmt.Errorf("hash pacman signature %s: %w", filepath.Base(signaturePath), err))
}
artifacts = append(artifacts, PacmanResolvedArtifact{
Name: resolvedName, Version: resolvedVersion, Repository: repository,
ArchivePath: archivePath, ArchiveSHA256: archiveHash,
SignaturePath: signaturePath, SignatureSHA256: signatureHash,
})
if resolvedName == packageName && (version == "" || resolvedVersion == version) {
rootFound = true
}
}
if len(artifacts) == 0 {
return fail(fmt.Errorf("pacman downloaded no package archives"))
}
if !rootFound {
return fail(fmt.Errorf("pacman transaction did not contain requested root %s@%s", packageName, version))
}
sort.Slice(artifacts, func(i, j int) bool {
if artifacts[i].Name == artifacts[j].Name {
return artifacts[i].Version < artifacts[j].Version
}
return artifacts[i].Name < artifacts[j].Name
})
return &PacmanResolution{Artifacts: artifacts, Cleanup: cleanup}, nil
}

View file

@ -0,0 +1,26 @@
//go:build linux
package installer
import "testing"
func TestParsePacmanPrint(t *testing.T) {
repositories, err := parsePacmanPrint("linux|1:6.19.14-1|core\nmkinitcpio|39.2-3|core\n")
if err != nil {
t.Fatal(err)
}
if got := repositories["linux@1:6.19.14-1"]; got != "core" {
t.Fatalf("epoch-bearing identity repository = %q, want core", got)
}
if got := repositories["mkinitcpio@39.2-3"]; got != "core" {
t.Fatalf("dependency repository = %q, want core", got)
}
}
func TestParsePacmanPrintRefusesMalformedIdentity(t *testing.T) {
for _, output := range []string{"", "linux 6.19 core", "linux|6.19|"} {
if _, err := parsePacmanPrint(output); err == nil {
t.Fatalf("parsePacmanPrint(%q) succeeded", output)
}
}
}

View file

@ -0,0 +1,9 @@
//go:build !linux
package installer
import "fmt"
func ResolvePacmanClosure(packageName, version string) (*PacmanResolution, error) {
return nil, fmt.Errorf("pacman resolution is only available on Linux")
}

View file

@ -0,0 +1,17 @@
package installer
// InstallResult represents the result of a package installation attempt
type InstallResult struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
Action string `json:"action,omitempty"` // "install", "upgrade", etc.
PackagesInstalled []string `json:"packages_installed,omitempty"`
ContainersUpdated []string `json:"containers_updated,omitempty"`
Dependencies []string `json:"dependencies,omitempty"` // List of dependency packages found during dry run
IsDryRun bool `json:"is_dry_run"` // Whether this is a dry run result
RebootRequired bool `json:"reboot_required,omitempty"` // Whether a reboot is needed (F-C1-3 ghost update fix)
}

View file

@ -0,0 +1,292 @@
package installer
import (
"fmt"
"log"
"runtime"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/pkg/windowsupdate"
"github.com/go-ole/go-ole"
"github.com/scjalliance/comshim"
)
// Windows Update Agent operation result codes.
// https://learn.microsoft.com/en-us/windows/win32/api/wuapi/ne-wuapi-operationresultcode
const (
orcSucceeded int32 = 2
orcSucceededWithErrors int32 = 3
)
// WindowsUpdateInstaller installs Windows updates through the Windows Update
// Agent COM API (Microsoft.Update.Session) via go-ole — the same binding the WUA
// scanner uses. No shelling out: wuauclt's /updatenow was removed on Windows 10,
// and Install-WindowsUpdate needs the third-party PSWindowsUpdate module. The COM
// binding compiles cross-platform (go-ole ships non-Windows stubs); IsAvailable()
// gates execution to Windows at runtime.
type WindowsUpdateInstaller struct{}
// NewWindowsUpdateInstaller creates a new Windows Update installer
func NewWindowsUpdateInstaller() *WindowsUpdateInstaller {
return &WindowsUpdateInstaller{}
}
// IsAvailable reports whether this installer can run on the current host.
func (i *WindowsUpdateInstaller) IsAvailable() bool {
return runtime.GOOS == "windows"
}
// GetPackageType returns the package type this installer handles
func (i *WindowsUpdateInstaller) GetPackageType() string {
return "windows_update"
}
// Install installs a specific Windows update by title.
func (i *WindowsUpdateInstaller) Install(packageName string) (*InstallResult, error) {
return i.installUpdates([]string{packageName}, false)
}
// InstallMultiple installs multiple Windows updates by title.
func (i *WindowsUpdateInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
return i.installUpdates(packageNames, false)
}
// Upgrade installs every available Windows update.
func (i *WindowsUpdateInstaller) Upgrade() (*InstallResult, error) {
return i.installUpdates(nil, true)
}
// UpdatePackage updates a specific Windows update (alias for Install).
func (i *WindowsUpdateInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
return i.Install(packageName)
}
// DryRun reports which updates would be installed for the given title without
// installing anything.
func (i *WindowsUpdateInstaller) DryRun(packageName, version string) (*InstallResult, error) {
return i.installUpdates([]string{packageName}, true)
}
// VerifyHash is a no-op for Windows Update: the WUA validates update payloads
// against Microsoft's signed catalog itself, and individual updates expose no
// addressable download URL to hash. Fail-open by design.
func (i *WindowsUpdateInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
log.Printf("[INFO] [agent] [installer] hash_verification_skipped package=%s reason=windows_update_uses_wua_verification", packageName)
return nil
}
// installUpdates searches the Windows Update Agent for the requested updates and
// runs the download -> accept-EULA -> install lifecycle through the COM API.
// packageNames are matched against update titles (the scanner reports Title as the
// package name); a nil/empty slice means "all available updates" (upgrade).
func (i *WindowsUpdateInstaller) installUpdates(packageNames []string, isDryRun bool) (*InstallResult, error) {
if !i.IsAvailable() {
return nil, fmt.Errorf("Windows Update installer is only available on Windows")
}
startTime := time.Now()
action := "install"
if len(packageNames) == 0 {
action = "upgrade"
}
result := &InstallResult{
Success: false,
IsDryRun: isDryRun,
Action: action,
PackagesInstalled: []string{},
Dependencies: []string{},
}
// Initialize COM (mirror the WUA scanner's pattern).
comshim.Add(1)
defer comshim.Done()
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
defer ole.CoUninitialize()
session, err := windowsupdate.NewUpdateSession()
if err != nil {
return i.fail(result, startTime, fmt.Errorf("create Windows Update session: %w", err))
}
searcher, err := session.CreateUpdateSearcher()
if err != nil {
return i.fail(result, startTime, fmt.Errorf("create update searcher: %w", err))
}
searchResult, err := searcher.Search("IsInstalled=0 AND IsHidden=0")
if err != nil {
return i.fail(result, startTime, fmt.Errorf("search for updates: %w", err))
}
selected := selectUpdates(searchResult.Updates, packageNames)
if len(selected) == 0 {
if len(packageNames) == 0 {
// Nothing to upgrade — a clean no-op, not a failure.
result.Success = true
result.Stdout = "No applicable Windows updates available"
result.DurationSeconds = int(time.Since(startTime).Seconds())
return result, nil
}
return i.fail(result, startTime,
fmt.Errorf("requested update(s) not found among available Windows updates: %v", packageNames))
}
if isDryRun {
result.Success = true
result.Stdout = formatSelected(selected)
result.PackagesInstalled = updateTitles(selected) // what WOULD be installed
result.DurationSeconds = int(time.Since(startTime).Seconds())
return result, nil
}
// Accept EULAs where required before download/install.
for _, u := range selected {
if !u.EulaAccepted {
if err := u.AcceptEula(); err != nil {
return i.fail(result, startTime, fmt.Errorf("accept EULA for %q: %w", u.Title, err))
}
}
}
// Download any updates not already cached.
downloader, err := session.CreateUpdateDownloader()
if err != nil {
return i.fail(result, startTime, fmt.Errorf("create update downloader: %w", err))
}
dlResult, err := downloader.Download(selected)
if err != nil {
return i.fail(result, startTime, fmt.Errorf("download updates: %w", err))
}
if !wuaSucceeded(dlResult.ResultCode) {
return i.fail(result, startTime,
fmt.Errorf("Windows Update download failed: ResultCode=%d HResult=0x%08X", dlResult.ResultCode, uint32(dlResult.HResult)))
}
// Install.
inst, err := session.CreateUpdateInstaller()
if err != nil {
return i.fail(result, startTime, fmt.Errorf("create update installer: %w", err))
}
instResult, err := inst.Install(selected)
if err != nil {
return i.fail(result, startTime, fmt.Errorf("install updates: %w", err))
}
if !wuaSucceeded(instResult.ResultCode) {
return i.fail(result, startTime,
fmt.Errorf("Windows Update install failed: ResultCode=%d HResult=0x%08X", instResult.ResultCode, uint32(instResult.HResult)))
}
result.Success = true
result.PackagesInstalled = updateTitles(selected)
result.RebootRequired = instResult.RebootRequired
result.Stdout = fmt.Sprintf("Installed %d Windows update(s): %s", len(selected), strings.Join(updateTitles(selected), "; "))
result.DurationSeconds = int(time.Since(startTime).Seconds())
log.Printf("[INFO] [agent] [installer] windows_update_installed packages=%v reboot_required=%v duration=%ds",
result.PackagesInstalled, result.RebootRequired, result.DurationSeconds)
return result, nil
}
// GetPendingUpdates returns the titles of updates the Windows Update Agent reports
// as applicable but not yet installed.
func (i *WindowsUpdateInstaller) GetPendingUpdates() ([]string, error) {
if !i.IsAvailable() {
return nil, fmt.Errorf("Windows Update installer is only available on Windows")
}
comshim.Add(1)
defer comshim.Done()
ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED|ole.COINIT_SPEED_OVER_MEMORY)
defer ole.CoUninitialize()
session, err := windowsupdate.NewUpdateSession()
if err != nil {
return nil, fmt.Errorf("create Windows Update session: %w", err)
}
searcher, err := session.CreateUpdateSearcher()
if err != nil {
return nil, fmt.Errorf("create update searcher: %w", err)
}
searchResult, err := searcher.Search("IsInstalled=0 AND IsHidden=0")
if err != nil {
return nil, fmt.Errorf("search for updates: %w", err)
}
return updateTitles(searchResult.Updates), nil
}
// fail finalizes a failed InstallResult: records the error, stamps duration, and
// returns it alongside the error so the caller reports ground truth (no fake success).
func (i *WindowsUpdateInstaller) fail(result *InstallResult, start time.Time, err error) (*InstallResult, error) {
result.Success = false
result.ErrorMessage = err.Error()
result.Stderr = err.Error()
result.ExitCode = 1
result.DurationSeconds = int(time.Since(start).Seconds())
return result, err
}
// selectUpdates picks the updates to act on. An empty names slice selects every
// available update (upgrade-all). Otherwise an update is selected when a requested
// name matches its title (exact, then case-insensitive contains) or one of its KB
// article IDs.
func selectUpdates(available []*windowsupdate.IUpdate, names []string) []*windowsupdate.IUpdate {
if len(names) == 0 {
return available
}
var selected []*windowsupdate.IUpdate
for _, u := range available {
if updateMatchesAny(u, names) {
selected = append(selected, u)
}
}
return selected
}
func updateMatchesAny(u *windowsupdate.IUpdate, names []string) bool {
title := strings.TrimSpace(u.Title)
lowerTitle := strings.ToLower(title)
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if title == name || strings.Contains(lowerTitle, strings.ToLower(name)) {
return true
}
for _, kb := range u.KBArticleIDs {
// KB IDs come back without the "KB" prefix; match either form.
if strings.EqualFold(kb, name) || strings.EqualFold("KB"+kb, name) {
return true
}
}
}
return false
}
func updateTitles(updates []*windowsupdate.IUpdate) []string {
titles := make([]string, 0, len(updates))
for _, u := range updates {
titles = append(titles, u.Title)
}
return titles
}
func formatSelected(updates []*windowsupdate.IUpdate) string {
var b strings.Builder
b.WriteString("Dry run - the following Windows updates would be installed:\n")
for _, u := range updates {
fmt.Fprintf(&b, " - %s", u.Title)
if len(u.KBArticleIDs) > 0 {
fmt.Fprintf(&b, " (KB%s)", strings.Join(u.KBArticleIDs, ", KB"))
}
b.WriteString("\n")
}
return b.String()
}
func wuaSucceeded(resultCode int32) bool {
return resultCode == orcSucceeded || resultCode == orcSucceededWithErrors
}

View file

@ -0,0 +1,391 @@
package installer
import (
"encoding/json"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
)
// WingetInstaller handles winget package installation
type WingetInstaller struct{}
// NewWingetInstaller creates a new Winget installer
func NewWingetInstaller() *WingetInstaller {
return &WingetInstaller{}
}
// IsAvailable checks if winget is available on this system
func (i *WingetInstaller) IsAvailable() bool {
// Only available on Windows
if runtime.GOOS != "windows" {
return false
}
// Check if winget command exists
_, err := exec.LookPath("winget")
return err == nil
}
// GetPackageType returns the package type this installer handles
func (i *WingetInstaller) GetPackageType() string {
return "winget"
}
// Install installs a specific winget package
func (i *WingetInstaller) Install(packageName string) (*InstallResult, error) {
return i.installPackage(packageName, false)
}
// InstallMultiple installs multiple winget packages
func (i *WingetInstaller) InstallMultiple(packageNames []string) (*InstallResult, error) {
if len(packageNames) == 0 {
return &InstallResult{
Success: false,
ErrorMessage: "No packages specified for installation",
}, fmt.Errorf("no packages specified")
}
// For winget, we'll install packages one by one to better track results
startTime := time.Now()
result := &InstallResult{
Success: true,
Action: "install_multiple",
PackagesInstalled: []string{},
Stdout: "",
Stderr: "",
ExitCode: 0,
DurationSeconds: 0,
}
var combinedStdout []string
var combinedStderr []string
for _, packageName := range packageNames {
singleResult, err := i.installPackage(packageName, false)
if err != nil {
result.Success = false
result.Stderr += fmt.Sprintf("Failed to install %s: %v\n", packageName, err)
continue
}
if !singleResult.Success {
result.Success = false
if singleResult.Stderr != "" {
combinedStderr = append(combinedStderr, fmt.Sprintf("%s: %s", packageName, singleResult.Stderr))
}
continue
}
result.PackagesInstalled = append(result.PackagesInstalled, packageName)
if singleResult.Stdout != "" {
combinedStdout = append(combinedStdout, fmt.Sprintf("%s: %s", packageName, singleResult.Stdout))
}
}
result.Stdout = strings.Join(combinedStdout, "\n")
result.Stderr = strings.Join(combinedStderr, "\n")
result.DurationSeconds = int(time.Since(startTime).Seconds())
if result.Success {
result.ExitCode = 0
} else {
result.ExitCode = 1
}
return result, nil
}
// Upgrade upgrades all outdated winget packages
func (i *WingetInstaller) Upgrade() (*InstallResult, error) {
if !i.IsAvailable() {
return nil, fmt.Errorf("winget is not available on this system")
}
startTime := time.Now()
// Get list of outdated packages first
outdatedPackages, err := i.getOutdatedPackages()
if err != nil {
return &InstallResult{
Success: false,
ErrorMessage: fmt.Sprintf("Failed to get outdated packages: %v", err),
}, err
}
if len(outdatedPackages) == 0 {
return &InstallResult{
Success: true,
Action: "upgrade",
Stdout: "No outdated packages found",
ExitCode: 0,
DurationSeconds: int(time.Since(startTime).Seconds()),
PackagesInstalled: []string{},
}, nil
}
// Upgrade all outdated packages
return i.upgradeAllPackages(outdatedPackages)
}
// DryRun performs a dry run installation to check what would be installed
func (i *WingetInstaller) DryRun(packageName, version string) (*InstallResult, error) {
return i.installPackage(packageName, true)
}
// installPackage is the internal implementation for package installation
func (i *WingetInstaller) installPackage(packageName string, isDryRun bool) (*InstallResult, error) {
if !i.IsAvailable() {
return nil, fmt.Errorf("winget is not available on this system")
}
startTime := time.Now()
result := &InstallResult{
Success: false,
IsDryRun: isDryRun,
ExitCode: 0,
DurationSeconds: 0,
}
// Build winget command
var cmd *exec.Cmd
if isDryRun {
// For dry run, we'll check if the package would be upgraded
cmd = exec.Command("winget", "show", "--id", packageName, "--accept-source-agreements")
result.Action = "dry_run"
} else {
// Install the package with upgrade flag
cmd = exec.Command("winget", "install", "--id", packageName,
"--upgrade", "--accept-package-agreements", "--accept-source-agreements", "--force")
result.Action = "install"
}
// Execute command
output, err := cmd.CombinedOutput()
result.Stdout = string(output)
result.Stderr = ""
result.DurationSeconds = int(time.Since(startTime).Seconds())
if err != nil {
result.ExitCode = 1
result.ErrorMessage = fmt.Sprintf("Command failed: %v", err)
// Check if this is a "no update needed" scenario
if strings.Contains(strings.ToLower(string(output)), "no upgrade available") ||
strings.Contains(strings.ToLower(string(output)), "already installed") {
result.Success = true
result.Stdout = "Package is already up to date"
result.ExitCode = 0
result.ErrorMessage = ""
}
return result, nil
}
result.Success = true
result.ExitCode = 0
result.PackagesInstalled = []string{packageName}
// Parse output to extract additional information
if !isDryRun {
result.Stdout = i.parseInstallOutput(string(output), packageName)
}
return result, nil
}
// getOutdatedPackages retrieves a list of outdated packages
func (i *WingetInstaller) getOutdatedPackages() ([]string, error) {
cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get outdated packages: %w", err)
}
var packages []WingetPackage
if err := json.Unmarshal(output, &packages); err != nil {
return nil, fmt.Errorf("failed to parse winget output: %w", err)
}
var outdatedNames []string
for _, pkg := range packages {
if pkg.Available != "" && pkg.Available != pkg.Version {
outdatedNames = append(outdatedNames, pkg.ID)
}
}
return outdatedNames, nil
}
// upgradeAllPackages upgrades all specified packages
func (i *WingetInstaller) upgradeAllPackages(packageIDs []string) (*InstallResult, error) {
startTime := time.Now()
result := &InstallResult{
Success: true,
Action: "upgrade",
PackagesInstalled: []string{},
Stdout: "",
Stderr: "",
ExitCode: 0,
DurationSeconds: 0,
}
var combinedStdout []string
var combinedStderr []string
for _, packageID := range packageIDs {
upgradeResult, err := i.installPackage(packageID, false)
if err != nil {
result.Success = false
combinedStderr = append(combinedStderr, fmt.Sprintf("Failed to upgrade %s: %v", packageID, err))
continue
}
if !upgradeResult.Success {
result.Success = false
if upgradeResult.Stderr != "" {
combinedStderr = append(combinedStderr, fmt.Sprintf("%s: %s", packageID, upgradeResult.Stderr))
}
continue
}
result.PackagesInstalled = append(result.PackagesInstalled, packageID)
if upgradeResult.Stdout != "" {
combinedStdout = append(combinedStdout, upgradeResult.Stdout)
}
}
result.Stdout = strings.Join(combinedStdout, "\n")
result.Stderr = strings.Join(combinedStderr, "\n")
result.DurationSeconds = int(time.Since(startTime).Seconds())
if result.Success {
result.ExitCode = 0
} else {
result.ExitCode = 1
}
return result, nil
}
// parseInstallOutput parses and formats winget install output
func (i *WingetInstaller) parseInstallOutput(output, packageName string) string {
lines := strings.Split(output, "\n")
var relevantLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Include important status messages
if strings.Contains(strings.ToLower(line), "successfully") ||
strings.Contains(strings.ToLower(line), "installed") ||
strings.Contains(strings.ToLower(line), "upgraded") ||
strings.Contains(strings.ToLower(line), "modified") ||
strings.Contains(strings.ToLower(line), "completed") ||
strings.Contains(strings.ToLower(line), "failed") ||
strings.Contains(strings.ToLower(line), "error") {
relevantLines = append(relevantLines, line)
}
// Include download progress
if strings.Contains(line, "Downloading") ||
strings.Contains(line, "Installing") ||
strings.Contains(line, "Extracting") {
relevantLines = append(relevantLines, line)
}
}
if len(relevantLines) == 0 {
return fmt.Sprintf("Package %s installation completed", packageName)
}
return strings.Join(relevantLines, "\n")
}
// parseDependencies analyzes package dependencies (winget doesn't explicitly expose dependencies)
func (i *WingetInstaller) parseDependencies(packageName string) ([]string, error) {
// Winget doesn't provide explicit dependency information in its basic output
// This is a placeholder for future enhancement where we might parse
// additional metadata or use Windows package management APIs
// For now, we'll return empty dependencies as winget handles this automatically
return []string{}, nil
}
// GetPackageInfo retrieves detailed information about a specific package
func (i *WingetInstaller) GetPackageInfo(packageID string) (map[string]interface{}, error) {
if !i.IsAvailable() {
return nil, fmt.Errorf("winget is not available on this system")
}
cmd := exec.Command("winget", "show", "--id", packageID, "--accept-source-agreements", "--output", "json")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get package info: %w", err)
}
var packageInfo map[string]interface{}
if err := json.Unmarshal(output, &packageInfo); err != nil {
return nil, fmt.Errorf("failed to parse package info: %w", err)
}
return packageInfo, nil
}
// IsPackageInstalled checks if a package is already installed
func (i *WingetInstaller) IsPackageInstalled(packageID string) (bool, string, error) {
if !i.IsAvailable() {
return false, "", fmt.Errorf("winget is not available on this system")
}
cmd := exec.Command("winget", "list", "--id", packageID, "--accept-source-agreements", "--output", "json")
output, err := cmd.Output()
if err != nil {
// Command failed, package is likely not installed
return false, "", nil
}
var packages []WingetPackage
if err := json.Unmarshal(output, &packages); err != nil {
return false, "", fmt.Errorf("failed to parse package list: %w", err)
}
if len(packages) > 0 {
return true, packages[0].Version, nil
}
return false, "", nil
}
// WingetPackage represents a winget package structure for JSON parsing
type WingetPackage struct {
Name string `json:"Name"`
ID string `json:"Id"`
Version string `json:"Version"`
Available string `json:"Available"`
Source string `json:"Source"`
IsPinned bool `json:"IsPinned"`
PinReason string `json:"PinReason,omitempty"`
}
// UpdatePackage updates a specific winget package (alias for Install method)
func (i *WingetInstaller) UpdatePackage(packageName string) (*InstallResult, error) {
// Winget uses same logic for updating as installing
return i.Install(packageName)
}
// VerifyHash verifies the winget package hash before installation
func (i *WingetInstaller) VerifyHash(packageName, version, expectedSHA256 string) error {
if expectedSHA256 == "" {
return fmt.Errorf("no expected hash registered for package %q — hash verification is mandatory for capability-gated installs", packageName)
}
// TODO: Winget packages should use Microsoft Store API for hash verification.
// Until implemented, fail closed — returning an error blocks the install.
return fmt.Errorf("hash verification not implemented for winget package %q — expected=%s", packageName, expectedSHA256)
}

View file

@ -0,0 +1,37 @@
// Package instancelock prevents multiple agent processes from running
// concurrently on the same host by holding an exclusive lock on a
// well-known path (Unix: flock on /var/lib/.../agent.lock; Windows:
// named kernel mutex Global\RedFlagAgent_v1 + per-user file lock).
// The lock is released when the process exits (the OS cleans up).
// If the lock cannot be acquired, the instance should exit immediately
// rather than share config.json and renewal state.
package instancelock
import (
"path/filepath"
)
// LockPath returns the well-known path for the Unix instance lockfile.
// Windows uses a named kernel mutex instead; this function is only called
// from lock_unix.go and is referenced here so the package compiles on
// Windows.
func LockPath() string {
return filepath.Join("/var/lib/redflag/agent/state", "agent.lock")
}
// Acquire attempts to acquire an exclusive instance lock. It returns a
// release function and nil on success, or an error if another agent
// process is already running on this host.
//
// The release function must be called on graceful shutdown.
// On process crash the OS releases the lock automatically:
// - Unix: the kernel closes the flock fd on process exit.
// - Windows: the kernel transitions the mutex to "abandoned" state;
// a crashed owner's mutex is acquired cleanly by the next waiter.
func Acquire() (release func(), err error) {
return acquireLock()
}
// noopRelease is a safe no-op for platforms that don't need cleanup.
func noopRelease() {}

View file

@ -0,0 +1,45 @@
//go:build linux || darwin || freebsd
package instancelock
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
// acquireLock opens (or creates) the lockfile and takes an exclusive
// flock. The lock is released when the process exits (the kernel
// closes the fd, which releases the flock).
func acquireLock() (func(), error) {
path := LockPath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("instancelock: mkdir %s: %w", dir, err)
}
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0644)
if err != nil {
return nil, fmt.Errorf("instancelock: open %s: %w", path, err)
}
fd := int(f.Fd())
if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
f.Close()
return nil, fmt.Errorf("instancelock: %s is locked by another process: %w", path, err)
}
// Write our PID so operators can see who holds the lock.
_, _ = f.WriteAt([]byte(fmt.Sprintf("%d\n", os.Getpid())), 0)
_ = f.Truncate(64)
release := func() {
_ = f.Close()
// Don't remove the file — leaving it is harmless and prevents a
// TOCTOU race where a fresh open might get an unlocked fd before
// we flock it.
}
return release, nil
}

View file

@ -0,0 +1,127 @@
//go:build windows
package instancelock
import (
"fmt"
"os"
"path/filepath"
"sync"
"golang.org/x/sys/windows"
)
var (
// mu guards handle — the OS mutex handle must be closed exactly once,
// but it must outlive the caller's release function (it's process-wide).
mu sync.Mutex
handle windows.Handle
)
// lockName is the well-known kernel-object name. The Global\ prefix makes
// it visible across all sessions (including the service session), which is
// necessary because the agent may run both as a service and as a console
// process under different sessions on the same host.
const lockName = `Global\RedFlagAgent_v1`
// acquireLock opens a named kernel mutex and attempts to acquire it
// without blocking. If the mutex is held by another process, we fail
// immediately. If it doesn't exist, CreateMutex creates it for us.
//
// Unlike the Unix flock (which lives on an fd scoped to the file system
// path), a Windows mutex is purely a kernel object — there is no file
// to write a PID or leak. The name is the key: any process on the system
// (service, console, WSL bridge) that opens the same name gets the same
// mutex object.
func acquireLock() (func(), error) {
mu.Lock()
defer mu.Unlock()
// If we already hold the lock in this process (shouldn't happen in
// normal use, but guard it), don't re-acquire.
if handle != 0 {
return noopRelease, nil
}
name, err := windows.UTF16PtrFromString(lockName)
if err != nil {
return nil, fmt.Errorf("instancelock: utf16: %w", err)
}
// CreateMutex opens or creates the named mutex. It does NOT set the
// initial ownership — we do that with WaitForSingleObject below.
h, err := windows.CreateMutex(nil, false, name)
if err != nil {
return nil, fmt.Errorf("instancelock: CreateMutex: %w", err)
}
// Attempt to acquire with zero timeout (non-blocking).
// WAIT_OBJECT_0 (0) = we got it.
// WAIT_ABANDONED (128) = previous owner died holding it — it's ours.
// WAIT_TIMEOUT (258) = someone else has it.
switch waitResult, _ := windows.WaitForSingleObject(h, 0); waitResult {
case 0, windows.WAIT_ABANDONED:
handle = h
case 258: // WAIT_TIMEOUT
_ = windows.CloseHandle(h)
return nil, fmt.Errorf("instancelock: another agent process is already running on this host (locked mutex: %s)", lockName)
default:
_ = windows.CloseHandle(h)
return nil, fmt.Errorf("instancelock: WaitForSingleObject failed on %s", lockName)
}
// Release is called on graceful shutdown. If the process crashes or
// is killed, the kernel releases the mutex automatically — unlike
// Unix where flock is tied to the fd and the fd closes on exit, a
// Windows mutex held by a dead thread transitions to "abandoned"
// and the next waiter acquires it cleanly.
release := func() {
mu.Lock()
defer mu.Unlock()
if handle != 0 {
_ = windows.ReleaseMutex(handle)
_ = windows.CloseHandle(handle)
handle = 0
}
}
// Also create a per-user lockfile under APPDATA to catch the case
// where the same user runs two console-mode agents without going
// through the SCM. The kernel mutex covers cross-session; the file
// lock covers same-user-duplicates where both instances open the
// same file.
localAppData := os.Getenv("LOCALAPPDATA")
if localAppData != "" {
lockFilePath := filepath.Join(localAppData, "RedFlag", "agent.lock")
// Best-effort: if we can't write it, the kernel mutex still
// protects us across all sessions.
_ = os.MkdirAll(filepath.Dir(lockFilePath), 0755)
if f, fErr := os.OpenFile(lockFilePath, os.O_RDWR|os.O_CREATE, 0644); fErr == nil {
if lErr := lockFile(f); lErr == nil {
// Extend release to also close the file.
prevRelease := release
release = func() {
_ = f.Close()
prevRelease()
}
} else {
_ = f.Close()
}
}
}
return release, nil
}
// lockFile takes an advisory lock on an *os.File using LockFileEx (the
// Windows equivalent of flock). This is belt-and-suspenders with the
// kernel mutex — both must be acquired for the lock to count.
func lockFile(f *os.File) error {
ol := &windows.Overlapped{}
return windows.LockFileEx(
windows.Handle(f.Fd()),
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
0, 1, 0, ol,
)
}

View file

@ -0,0 +1,23 @@
// Package integrations detects third-party software the agent can observe on its
// own host (Sunshine, etc.) and folds a compact report into the agent's regular
// system-info payload under metadata["integrations"].
//
// Architecture: observe-only. Detection inspects the local host and reports what
// it finds. It never starts, stops, configures, or otherwise commands the software
// it observes — that boundary is what keeps a remote-desktop integration from
// becoming a remote-control backdoor. The dashboard renders what the agent reports;
// it does not reach into the host.
package integrations
// Detect returns the integrations block for metadata["integrations"]. primaryIP is
// the agent's primary address, used to construct local management URLs (e.g. the
// Sunshine web UI). An empty map means nothing was detected — callers may omit it.
func Detect(primaryIP string) map[string]interface{} {
out := make(map[string]interface{})
if s := detectSunshine(primaryIP); s != nil {
out["sunshine"] = s
}
return out
}

View file

@ -0,0 +1,123 @@
package integrations
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"strings"
"time"
)
// sunshineDefaultWebPort is Sunshine's default HTTPS management UI port. If the
// operator moved it, the constructed URL will be wrong — that's a known limit of
// observe-only detection until we parse sunshine.conf (a later slice).
const sunshineDefaultWebPort = 47990
// sunshineReport mirrors the ReportedIntegration contract the web UI reads
// (web/src/types/integrations.ts). JSON keys must stay in sync.
type sunshineReport struct {
State string `json:"state"` // detected | running | not_detected
Version string `json:"version,omitempty"` // observed via `sunshine --version`
WebUI string `json:"web_ui,omitempty"` // https://<ip>:47990 (default)
SessionActive bool `json:"session_active"` // not yet observable (slice 1)
LastObserved string `json:"last_observed"` // RFC3339, when detection ran
}
// detectSunshine inspects the host for Sunshine. Returns nil only if Sunshine is
// neither installed nor running (so the integration block stays empty rather than
// carrying a noisy "not_detected" for every host that never had it). Observe-only:
// the only subprocess it ever spawns is `sunshine --version`, which is read-only.
func detectSunshine(primaryIP string) *sunshineReport {
installed := false
if _, err := exec.LookPath("sunshine"); err == nil {
installed = true
}
running := sunshineRunning()
if !installed && !running {
return nil
}
rep := &sunshineReport{
LastObserved: time.Now().UTC().Format(time.RFC3339),
}
switch {
case running:
rep.State = "running"
default:
rep.State = "detected"
}
if installed {
if v := sunshineVersion(); v != "" {
rep.Version = v
}
}
if primaryIP != "" {
rep.WebUI = fmt.Sprintf("https://%s:%d", primaryIP, sunshineDefaultWebPort)
}
log.Printf("[INFO] [agent] [integrations] sunshine_detected state=%s version=%q", rep.State, rep.Version)
return rep
}
// sunshineRunning reports whether a sunshine process is currently live. Uses the
// same plain-exec, GOOS-switched idiom as the rest of the system package — no new
// dependency. Best-effort: a failure to enumerate returns false, never an error.
func sunshineRunning() bool {
const name = "sunshine"
switch runtime.GOOS {
case "linux", "darwin":
// pgrep is the cheapest exact-match; exit 0 means a match exists.
if exec.Command("pgrep", "-x", name).Run() == nil {
return true
}
// Fallback for hosts without pgrep: scan the process table.
if out, err := exec.Command("ps", "-e", "-o", "comm").Output(); err == nil {
return processListContains(string(out), name)
}
case "windows":
if out, err := exec.Command("tasklist", "/fo", "csv", "/nh").Output(); err == nil {
return processListContains(strings.ToLower(string(out)), name)
}
}
return false
}
func processListContains(output, name string) bool {
for _, line := range strings.Split(output, "\n") {
if strings.Contains(strings.TrimSpace(line), name) {
return true
}
}
return false
}
// sunshineVersion reads the reported version. `sunshine --version` is read-only and
// does not start the streaming service. Guarded by a short timeout so a wedged
// binary can never stall the system-info report (ETHOS #3 — assume failure).
func sunshineVersion() string {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "sunshine", "--version").CombinedOutput()
if err != nil {
return ""
}
// Output is typically a single line like "Sunshine version: v0.23.1".
line := strings.TrimSpace(string(out))
if line == "" {
return ""
}
if idx := strings.LastIndex(line, ":"); idx >= 0 && idx+1 < len(line) {
return strings.TrimSpace(line[idx+1:])
}
// Fall back to the last whitespace-delimited token.
fields := strings.Fields(line)
if len(fields) > 0 {
return fields[len(fields)-1]
}
return line
}

View file

@ -0,0 +1,310 @@
package kernel
import (
"bytes"
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"sync"
"syscall"
"time"
"unsafe"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
)
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
teeLogger *event.TeeLogger
}
// NewEBPFConsumer creates a new eBPF ring buffer consumer
func NewEBPFConsumer(cfg *config.Config) (*EBPFConsumer, error) {
return NewEBPFConsumerWithLogger(cfg, nil)
}
func NewEBPFConsumerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (*EBPFConsumer, error) {
ctx, cancel := context.WithCancel(context.Background())
return &EBPFConsumer{
config: cfg,
ctx: ctx,
cancel: cancel,
elfPath: cfg.KernelEnforcement.RingBufferPath + "/pkg-gate.o",
teeLogger: teeLogger,
}, 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
}
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("ringbuf_read_failed error=%v", err), map[string]interface{}{"error": err.Error()})
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 {
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("invalid_magic magic=0x%x expected=0x%x", event.Magic, MAGIC_NUMBER), map[string]interface{}{
"magic": fmt.Sprintf("0x%x", event.Magic),
"expected": fmt.Sprintf("0x%x", MAGIC_NUMBER),
})
return
}
// Forward to rs-helper for policy decision
decision, reason, err := e.checkPolicy(event)
if err != nil {
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("policy_check_failed error=%v", err), map[string]interface{}{"error": err.Error()})
return
}
if decision == "deny" {
e.teeLogger.Error("agent", "kernel", "kernel_enforcer", fmt.Sprintf("execve_deny pid=%d comm=%s reason=%s", event.PID, event.Comm, reason), map[string]interface{}{
"pid": event.PID,
"comm": fmt.Sprintf("%s", event.Comm),
"reason": 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,84 @@
package kernel
import (
"context"
"log"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/event"
)
type Enforcer interface {
Start(ctx context.Context) error
Stop() error
GetPackageType() string
}
type EnforcerFactory func(*config.Config, *event.TeeLogger) (Enforcer, error)
var enforcers = map[string]EnforcerFactory{}
func RegisterEnforcer(pkgType string, factory EnforcerFactory) {
enforcers[pkgType] = factory
}
func NewEnforcerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (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, teeLogger)
}
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) {
return NewEBPFEnforcerWithLogger(cfg, nil)
}
func NewEBPFEnforcerWithLogger(cfg *config.Config, teeLogger *event.TeeLogger) (*EBPFEnforcer, error) {
consumer, err := NewEBPFConsumerWithLogger(cfg, teeLogger)
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, teeLogger *event.TeeLogger) (Enforcer, error) {
consumer, err := NewEBPFConsumerWithLogger(cfg, teeLogger)
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

@ -0,0 +1,100 @@
package localapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/cache"
)
func approveHandler(t *testing.T, approve func(body []byte) (interface{}, error)) http.Handler {
t.Helper()
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, ApproveUpdate: approve})
}
func postApprove(t *testing.T, handler http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/approve-update", strings.NewReader(body)))
return rec
}
func TestApproveUpdateSuccess(t *testing.T) {
var gotBody string
handler := approveHandler(t, func(body []byte) (interface{}, error) {
gotBody = string(body)
return map[string]interface{}{"request_id": "req-9", "osv_status": "clear"}, nil
})
rec := postApprove(t, handler, `{"package_type":"dnf","package_name":"hyprutils"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(gotBody, "hyprutils") {
t.Fatalf("callback body = %q, want raw request body", gotBody)
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["request_id"] != "req-9" {
t.Fatalf("request_id = %v, want req-9", resp["request_id"])
}
}
func TestApproveUpdateConflictIs409(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) {
return nil, fmt.Errorf("%w: gate refused", ErrApprovalConflict)
})
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if !strings.Contains(rec.Body.String(), "gate refused") {
t.Fatalf("body = %s, want gate refusal detail", rec.Body.String())
}
}
func TestApproveUpdateNoAuthorityIs503(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) {
return nil, fmt.Errorf("%w: no local authority", ErrApprovalUnavailable)
})
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestApproveUpdateNilCallbackIs503(t *testing.T) {
handler := approveHandler(t, nil)
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestApproveUpdateInternalErrorIs500(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) {
return nil, errors.New("dry run exploded")
})
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}
func TestApproveUpdateRejectsGet(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) { return nil, nil })
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/approve-update", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}

View file

@ -0,0 +1,86 @@
package localapi
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"time"
)
const localHTTPBaseURL = "http://redflag.local"
// ClientOptions configures local IPC client access. Defaults mirror the agent
// listener and remain platform-specific.
type ClientOptions struct {
UnixSocketPath string
WindowsPipeName string
Timeout time.Duration
}
// Snapshot is the compact local state needed by status probes and Desktop.
type Snapshot struct {
Identity IdentityResponse `json:"identity"`
Status StatusResponse `json:"status"`
}
// FetchSnapshot reads the local agent API over the platform IPC transport.
func FetchSnapshot(ctx context.Context, opts ClientOptions) (*Snapshot, error) {
httpClient := newLocalHTTPClient(opts)
identity, err := fetchJSON[IdentityResponse](ctx, httpClient, "/v1/identity")
if err != nil {
return nil, fmt.Errorf("fetch identity: %w", err)
}
status, err := fetchJSON[StatusResponse](ctx, httpClient, "/v1/status")
if err != nil {
return nil, fmt.Errorf("fetch status: %w", err)
}
return &Snapshot{
Identity: identity,
Status: status,
}, nil
}
func newLocalHTTPClient(opts ClientOptions) *http.Client {
timeout := opts.Timeout
if timeout == 0 {
timeout = 5 * time.Second
}
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
DialContext: dialLocalContext(opts),
DisableKeepAlives: true,
Proxy: nil,
},
}
}
func fetchJSON[T any](ctx context.Context, httpClient *http.Client, path string) (T, error) {
var value T
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localHTTPBaseURL+path, nil)
if err != nil {
return value, err
}
resp, err := httpClient.Do(req)
if err != nil {
return value, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return value, fmt.Errorf("local API returned %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(&value); err != nil {
return value, err
}
return value, nil
}
func dialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
return platformDialLocalContext(opts)
}

View file

@ -0,0 +1,20 @@
//go:build !windows
package localapi
import (
"context"
"net"
)
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
path := opts.UnixSocketPath
if path == "" {
path = defaultUnixSocketPath()
}
return func(ctx context.Context, _, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", path)
}
}

View file

@ -0,0 +1,63 @@
//go:build !windows
package localapi
import (
"context"
"errors"
"net"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
)
func TestFetchSnapshotOverUnixSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "redflag-agent.sock")
ln, err := net.Listen("unix", socketPath)
if err != nil {
if errors.Is(err, syscall.EPERM) {
t.Skipf("unix sockets are not permitted in this sandbox: %v", err)
}
t.Fatalf("listen unix socket: %v", err)
}
cfg := testConfig(t)
server, err := Start(Options{
Config: cfg,
LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{
AgentStatus: "online",
UpdateCount: 7,
Summary: cache.UpdateSummary{
Total: 7,
},
}, nil
},
RequestLog: t.Logf,
ListenerOverride: ln,
})
if err != nil {
t.Fatalf("start local API: %v", err)
}
defer server.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
snapshot, err := FetchSnapshot(ctx, ClientOptions{UnixSocketPath: socketPath})
if err != nil {
t.Fatalf("fetch snapshot: %v", err)
}
if snapshot.Identity.AgentID != cfg.AgentID.String() {
t.Fatalf("agent_id = %q, want %q", snapshot.Identity.AgentID, cfg.AgentID.String())
}
if snapshot.Status.AgentStatus != "online" {
t.Fatalf("agent_status = %q, want online", snapshot.Status.AgentStatus)
}
if snapshot.Status.UpdateCount != 7 {
t.Fatalf("update_count = %d, want 7", snapshot.Status.UpdateCount)
}
}

View file

@ -0,0 +1,21 @@
//go:build windows
package localapi
import (
"context"
"net"
winio "github.com/Microsoft/go-winio"
)
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
pipeName := opts.WindowsPipeName
if pipeName == "" {
pipeName = DefaultWindowsPipeName
}
return func(ctx context.Context, _, _ string) (net.Conn, error) {
return winio.DialPipeContext(ctx, pipeName)
}
}

View file

@ -0,0 +1,109 @@
//go:build !windows
package localapi
import (
"fmt"
"net"
"os"
"os/user"
"path/filepath"
"strconv"
"github.com/Fimeg/RedFlag/agent/internal/constants"
)
const unixSocketFile = "redflag-agent.sock"
func defaultGroupName() string {
return DefaultUnixGroupName
}
func defaultUnixSocketPath() string {
return filepath.Join(constants.GetBaseDir(), constants.AgentDir, "localapi", unixSocketFile)
}
func listen(opts Options) (net.Listener, string, error) {
path := unixSocketPath(opts)
group := groupName(opts)
gid, err := lookupGroupID(group)
if err != nil {
return nil, "", formatGroupMissing(group, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, "", fmt.Errorf("localapi: create socket directory %q: %w", dir, err)
}
if err := os.Chown(dir, -1, gid); err != nil {
return nil, "", fmt.Errorf("localapi: chown socket directory %q to group %q: %w", dir, group, err)
}
if err := os.Chmod(dir, 0o750); err != nil {
return nil, "", fmt.Errorf("localapi: chmod socket directory %q: %w", dir, err)
}
if err := removeStaleSocket(path); err != nil {
return nil, "", err
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, "", fmt.Errorf("localapi: listen on unix socket %q: %w", path, err)
}
if err := os.Chown(path, -1, gid); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, "", fmt.Errorf("localapi: chown socket %q to group %q: %w", path, group, err)
}
if err := os.Chmod(path, 0o660); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, "", fmt.Errorf("localapi: chmod socket %q: %w", path, err)
}
return &cleanupListener{Listener: ln, path: path}, path, nil
}
func lookupGroupID(groupName string) (int, error) {
group, err := user.LookupGroup(groupName)
if err != nil {
return 0, err
}
gid, err := strconv.Atoi(group.Gid)
if err != nil {
return 0, fmt.Errorf("parse gid %q: %w", group.Gid, err)
}
return gid, nil
}
func removeStaleSocket(path string) error {
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("localapi: stat socket path %q: %w", path, err)
}
if info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("localapi: refusing to replace non-socket path %q", path)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("localapi: remove stale socket %q: %w", path, err)
}
return nil
}
type cleanupListener struct {
net.Listener
path string
}
func (l *cleanupListener) Close() error {
err := l.Listener.Close()
if removeErr := os.Remove(l.path); err == nil && removeErr != nil && !os.IsNotExist(removeErr) {
err = removeErr
}
return err
}

View file

@ -0,0 +1,54 @@
//go:build windows
package localapi
import (
"fmt"
"net"
winio "github.com/Microsoft/go-winio"
"golang.org/x/sys/windows"
)
func defaultGroupName() string {
return DefaultWindowsGroupName
}
func defaultUnixSocketPath() string {
return ""
}
func listen(opts Options) (net.Listener, string, error) {
pipeName := windowsPipeName(opts)
group := groupName(opts)
groupSID, err := lookupGroupSID(group)
if err != nil {
return nil, "", formatGroupMissing(group, err)
}
cfg := &winio.PipeConfig{
// LocalSystem and Administrators get full access; the RedFlag local UI
// group gets read/write transport rights so it can issue HTTP GETs and
// receive responses over the pipe.
SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;%s)", groupSID),
}
ln, err := winio.ListenPipe(pipeName, cfg)
if err != nil {
return nil, "", fmt.Errorf("localapi: listen on named pipe %q: %w", pipeName, err)
}
return ln, pipeName, nil
}
func lookupGroupSID(groupName string) (string, error) {
sid, _, accountType, err := windows.LookupSID("", groupName)
if err != nil {
return "", err
}
switch accountType {
case windows.SidTypeGroup, windows.SidTypeAlias, windows.SidTypeWellKnownGroup:
return sid.String(), nil
default:
return "", fmt.Errorf("account %q has unexpected SID type %d", groupName, accountType)
}
}

View file

@ -0,0 +1,829 @@
package localapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
const (
DefaultUnixGroupName = "redflag-local"
DefaultWindowsGroupName = "RedFlagLocal"
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
)
// ErrScanInFlight is returned by a TriggerScan callback while a previously
// triggered scan is still running. The handler maps it to 409 Conflict.
var ErrScanInFlight = errors.New("localapi: scan already in flight")
// Approval callback sentinels. The loop wiring translates handler/supplychain
// errors into these so this package stays decoupled from the approval stack.
var (
// ErrApprovalConflict → 409: gate refused, fleet mode, duplicate, or an
// approval already in flight. The wrapped message carries the specifics.
ErrApprovalConflict = errors.New("localapi: approval conflict")
// ErrApprovalUnavailable → 503: no local authority on this host.
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
)
// Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation.
type Options struct {
Config *config.Config
GroupName string
UnixSocketPath string
WindowsPipeName string
LoadCache func() (*cache.LocalCache, error)
RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener
DesktopProvider DesktopStatusProvider
SystemProvider func() (*system.SystemInfo, error)
ProcessProvider func(limit int) ([]system.TopProcess, error)
MonitorProvider func() (*system.ResourceSnapshot, error)
ProcessesProvider func() (*system.FullProcessSnapshot, error)
ProcessDetailProvider func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
SoftwareProvider func() (*system.SoftwareSnapshot, error)
PackageDetailProvider func(packageType, identity string) (*system.PackageDetail, error)
ConnectionsProvider func() (*system.ConnectionSnapshot, error)
ServicesProvider func() (*system.ServiceSnapshot, error)
DockerProvider func() (*DockerResponse, error)
EventsProvider func() ([]*models.SystemEvent, error)
ScanRunning func() bool
// TriggerScan enqueues a package-update scan through the agent's existing
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
// group boundary on the socket/pipe — anyone who can connect may trigger.
// Nil disables the endpoint (503). Return ErrScanInFlight to signal 409.
TriggerScan func(source string) error
// ApproveUpdate runs the standalone approval flow (FEAT-003): gates →
// mint → execute. Synchronous; the response carries the full verdict.
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
// ErrApprovalUnavailable to control the HTTP status.
ApproveUpdate func(body []byte) (interface{}, error)
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
// agent can track app liveness/version — on Linux Desktop is autostart-
// launched and this is the only signal. Nil means reports are logged only.
OnDesktopHealth func(version string, windowOpen bool)
}
// Server owns the local API listener and HTTP server.
type Server struct {
httpServer *http.Server
listener net.Listener
address string
logf func(format string, args ...interface{})
monitor *system.ResourceMonitor
}
// Start creates a platform-native local listener and serves the read-only local
// API. It returns an error before serving if the OS-local ACL cannot be applied.
func Start(opts Options) (*Server, error) {
if opts.Config == nil {
return nil, errors.New("localapi: config is required")
}
if opts.LoadCache == nil {
opts.LoadCache = cache.Load
}
if opts.RequestLog == nil {
opts.RequestLog = log.Printf
}
var monitor *system.ResourceMonitor
if opts.MonitorProvider == nil {
monitor = system.NewResourceMonitor(time.Second, 300)
monitor.Start()
opts.MonitorProvider = monitor.Snapshot
}
handler := newHandler(opts)
httpServer := &http.Server{Handler: handler}
listener := opts.ListenerOverride
address := ""
var err error
if listener == nil {
listener, address, err = listen(opts)
if err != nil {
if monitor != nil {
monitor.Stop()
}
return nil, err
}
} else {
address = listener.Addr().String()
}
srv := &Server{
httpServer: httpServer,
listener: listener,
address: address,
logf: opts.RequestLog,
monitor: monitor,
}
go func() {
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
opts.RequestLog("[ERROR] [agent] [localapi] serve_failed address=%s error=%v", address, err)
}
}()
opts.RequestLog("[INFO] [agent] [localapi] started address=%s", address)
return srv, nil
}
// Stop shuts down the local API listener.
func (s *Server) Stop() {
if s == nil || s.httpServer == nil {
return
}
if err := s.httpServer.Close(); err != nil && s.logf != nil {
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
}
if s.monitor != nil {
s.monitor.Stop()
}
}
type handler struct {
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
approveUpdate func(body []byte) (interface{}, error)
onDesktopHealth func(version string, windowOpen bool)
systemInfo func() (*system.SystemInfo, error)
topProcesses func(limit int) ([]system.TopProcess, error)
monitorSnapshot func() (*system.ResourceSnapshot, error)
processes func() (*system.FullProcessSnapshot, error)
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
software func() (*system.SoftwareSnapshot, error)
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
connections func() (*system.ConnectionSnapshot, error)
services func() (*system.ServiceSnapshot, error)
docker func() (*DockerResponse, error)
events func() ([]*models.SystemEvent, error)
scanRunning func() bool
}
// DesktopStatusProvider allows the desktop manager to report its status.
type DesktopStatusProvider interface {
Status() (running bool, pid int)
}
type IdentityResponse struct {
AgentID string `json:"agent_id"`
ServerURL string `json:"server_url"`
Hostname string `json:"hostname,omitempty"`
OSType string `json:"os_type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Organization string `json:"organization,omitempty"`
Tags []string `json:"tags,omitempty"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version,omitempty"`
CheckInInterval int `json:"check_in_interval"`
Registered bool `json:"registered"`
}
type StatusResponse struct {
ScanRunning bool `json:"scan_running"`
AgentStatus string `json:"agent_status"`
LastCheckIn time.Time `json:"last_check_in,omitempty"`
LastUpdated time.Time `json:"last_updated,omitempty"`
LastScan time.Time `json:"last_scan_time,omitempty"`
UpdateCount int `json:"update_count"`
Summary cache.UpdateSummary `json:"summary"`
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
Registered bool `json:"registered"`
Desktop *DesktopStatus `json:"desktop,omitempty"`
}
type DesktopStatus struct {
Running bool `json:"running"`
PID int `json:"pid,omitempty"`
Enabled bool `json:"enabled"`
}
// DesktopHealthRequest is sent by the desktop app to report its health.
type DesktopHealthRequest struct {
Version string `json:"version"`
Uptime int64 `json:"uptime_seconds"`
WindowOpen bool `json:"window_open"`
}
type ScanResponse struct {
LastScanTime time.Time `json:"last_scan_time,omitempty"`
LastUpdated time.Time `json:"last_updated,omitempty"`
UpdateCount int `json:"update_count"`
Summary cache.UpdateSummary `json:"summary"`
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
Updates []client.UpdateReportItem `json:"updates"`
}
type TokenResponse struct {
Capabilities cache.CapabilityTokenState `json:"capabilities"`
}
type SystemResponse struct {
System *system.SystemInfo `json:"system"`
TopProcesses []system.TopProcess `json:"top_processes"`
CollectedAt time.Time `json:"collected_at"`
}
type DockerResponse struct {
Available bool `json:"available"`
Version string `json:"version,omitempty"`
Containers []client.DockerReportContainer `json:"containers"`
Stacks []client.DockerReportStack `json:"stacks"`
Count int `json:"count"`
Running int `json:"running"`
Unhealthy int `json:"unhealthy"`
CollectedAt time.Time `json:"collected_at"`
}
type EventsResponse struct {
Events []*models.SystemEvent `json:"events"`
Count int `json:"count"`
CollectedAt time.Time `json:"collected_at"`
}
type SecurityResponse struct {
CommandSigningEnabled bool `json:"command_signing_enabled"`
CommandEnforcement string `json:"command_enforcement"`
TLSVerification bool `json:"tls_verification"`
SecurityLogging bool `json:"security_logging"`
KernelEnforcement bool `json:"kernel_enforcement"`
KernelFailClosed bool `json:"kernel_fail_closed"`
DegradedMode bool `json:"degraded_mode"`
Registered bool `json:"registered"`
CriticalUpdates int `json:"critical_updates"`
HighUpdates int `json:"high_updates"`
Capabilities cache.CapabilityTokenState `json:"capabilities"`
CollectedAt time.Time `json:"collected_at"`
}
func newHandler(opts Options) http.Handler {
h := &handler{
cfg: opts.Config,
loadCache: opts.LoadCache,
desktop: opts.DesktopProvider,
triggerScan: opts.TriggerScan,
approveUpdate: opts.ApproveUpdate,
onDesktopHealth: opts.OnDesktopHealth,
systemInfo: opts.SystemProvider,
topProcesses: opts.ProcessProvider,
monitorSnapshot: opts.MonitorProvider,
processes: opts.ProcessesProvider,
processDetail: opts.ProcessDetailProvider,
software: opts.SoftwareProvider,
packageDetail: opts.PackageDetailProvider,
connections: opts.ConnectionsProvider,
services: opts.ServicesProvider,
docker: opts.DockerProvider,
events: opts.EventsProvider,
scanRunning: opts.ScanRunning,
}
if h.systemInfo == nil {
h.systemInfo = func() (*system.SystemInfo, error) {
return system.GetSystemInfo(version.Version)
}
}
if h.topProcesses == nil {
h.topProcesses = system.GetTopProcesses
}
if h.processes == nil {
h.processes = system.GetFullProcessSnapshot
}
if h.processDetail == nil {
h.processDetail = system.GetProcessDetail
}
if h.software == nil {
h.software = system.GetSoftwareSnapshot
}
if h.packageDetail == nil {
h.packageDetail = system.GetPackageDetail
}
if h.connections == nil {
h.connections = system.GetConnectionsSnapshot
}
if h.services == nil {
h.services = system.GetServicesSnapshot
}
if h.loadCache == nil {
h.loadCache = cache.Load
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/identity", h.identity)
mux.HandleFunc("/v1/status", h.status)
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
mux.HandleFunc("/v1/packages", h.scansLatest)
mux.HandleFunc("/v1/system", h.system)
mux.HandleFunc("/v1/monitor", h.monitor)
mux.HandleFunc("/v1/processes", h.processList)
mux.HandleFunc("/v1/processes/", h.processByPID)
mux.HandleFunc("/v1/software", h.softwareList)
mux.HandleFunc("/v1/software/detail", h.softwareDetail)
mux.HandleFunc("/v1/connections", h.connectionList)
mux.HandleFunc("/v1/services", h.serviceList)
mux.HandleFunc("/v1/containers", h.containerList)
mux.HandleFunc("/v1/events", h.eventList)
mux.HandleFunc("/v1/security", h.security)
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
return mux
}
func (h *handler) containerList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
if h.docker == nil {
writeJSON(w, DockerResponse{Available: false, CollectedAt: time.Now().UTC()})
return
}
snapshot, err := h.docker()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] container_inventory_failed error=%v", err)
http.Error(w, "container inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) eventList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
if h.events == nil {
writeJSON(w, EventsResponse{CollectedAt: time.Now().UTC()})
return
}
events, err := h.events()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] event_history_failed error=%v", err)
http.Error(w, "local event history unavailable", http.StatusServiceUnavailable)
return
}
sort.Slice(events, func(i, j int) bool { return events[i].CreatedAt.After(events[j].CreatedAt) })
writeJSON(w, EventsResponse{Events: events, Count: len(events), CollectedAt: time.Now().UTC()})
}
func (h *handler) security(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
response := SecurityResponse{
CommandSigningEnabled: h.cfg.CommandSigning.Enabled,
CommandEnforcement: h.cfg.CommandSigning.EnforcementMode,
TLSVerification: !h.cfg.TLS.InsecureSkipVerify,
SecurityLogging: h.cfg.SecurityLogging.Enabled,
KernelEnforcement: h.cfg.KernelEnforcement.Enabled,
KernelFailClosed: h.cfg.KernelEnforcement.FailClosed,
DegradedMode: h.cfg.DegradedMode,
Registered: h.cfg.IsRegistered(),
CriticalUpdates: localCache.Summary.BySeverity["critical"],
HighUpdates: localCache.Summary.BySeverity["high"] + localCache.Summary.BySeverity["important"],
Capabilities: localCache.Capabilities,
CollectedAt: time.Now().UTC(),
}
writeJSON(w, response)
}
func (h *handler) connectionList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.connections()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] connection_inventory_failed error=%v", err)
http.Error(w, "connection inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) serviceList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.services()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] service_inventory_failed error=%v", err)
http.Error(w, "service inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) monitor(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
if h.monitorSnapshot == nil {
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
return
}
snapshot, err := h.monitorSnapshot()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] resource_monitor_failed error=%v", err)
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) processList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.processes()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] process_inventory_failed error=%v", err)
http.Error(w, "process inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) processByPID(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
rawPID := strings.TrimPrefix(r.URL.Path, "/v1/processes/")
pid, err := strconv.Atoi(rawPID)
if err != nil || pid <= 0 || strings.Contains(rawPID, "/") {
http.Error(w, "invalid process id", http.StatusBadRequest)
return
}
caps := processCaps(h.cfg.ProcessExplorer)
process, err := h.processDetail(pid, caps)
if err != nil {
log.Printf("[INFO] [agent] [localapi] process_detail_unavailable pid=%d error=%v", pid, err)
http.Error(w, "process unavailable", http.StatusNotFound)
return
}
writeJSON(w, process)
}
func (h *handler) softwareList(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
snapshot, err := h.software()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] software_inventory_failed error=%v", err)
http.Error(w, "software inventory unavailable", http.StatusServiceUnavailable)
return
}
writeJSON(w, snapshot)
}
func (h *handler) softwareDetail(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
packageType := strings.TrimSpace(r.URL.Query().Get("manager"))
identity := strings.TrimSpace(r.URL.Query().Get("identity"))
if packageType == "" || identity == "" {
http.Error(w, "manager and identity are required", http.StatusBadRequest)
return
}
detail, err := h.packageDetail(packageType, identity)
if err != nil {
log.Printf("[INFO] [agent] [localapi] software_detail_unavailable manager=%s identity=%s error=%v", packageType, identity, err)
http.Error(w, "software detail unavailable", http.StatusNotFound)
return
}
writeJSON(w, detail)
}
func processCaps(cfg config.ProcessExplorerConfig) system.ProcessCaps {
capOr := func(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
return system.ProcessCaps{
MaxOpenFiles: capOr(cfg.MaxOpenFiles, 2000),
MaxSockets: capOr(cfg.MaxSockets, 500),
MaxPipes: capOr(cfg.MaxPipes, 500),
MaxMemoryMap: capOr(cfg.MaxMemoryMap, 2000),
MaxNamespaces: capOr(cfg.MaxNamespaces, 50),
MaxEnvKeys: capOr(cfg.MaxEnvKeys, 200),
MaxListeningPorts: capOr(cfg.MaxListeningPorts, 100),
}
}
func (h *handler) system(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
info, err := h.systemInfo()
if err != nil {
log.Printf("[ERROR] [agent] [localapi] system_info_failed error=%v", err)
http.Error(w, "system health unavailable", http.StatusServiceUnavailable)
return
}
processes, err := h.topProcesses(8)
if err != nil {
log.Printf("[WARNING] [agent] [localapi] top_processes_failed error=%v", err)
processes = nil
}
writeJSON(w, SystemResponse{
System: info,
TopProcesses: processes,
CollectedAt: time.Now().UTC(),
})
}
func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
hostname, err := os.Hostname()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
}
resp := IdentityResponse{
AgentID: h.cfg.AgentID.String(),
ServerURL: h.cfg.ServerURL,
Hostname: hostname,
OSType: h.cfg.OSType,
DisplayName: h.cfg.DisplayName,
Organization: h.cfg.Organization,
Tags: sortedCopy(h.cfg.Tags),
AgentVersion: version.Version,
ConfigVersion: h.cfg.Version,
CheckInInterval: h.cfg.CheckInInterval,
Registered: h.cfg.IsRegistered(),
}
writeJSON(w, resp)
}
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
resp := StatusResponse{
ScanRunning: h.scanRunning != nil && h.scanRunning(),
AgentStatus: localCache.AgentStatus,
LastCheckIn: localCache.LastCheckIn,
LastUpdated: localCache.LastUpdated,
LastScan: localCache.LastScanTime,
UpdateCount: localCache.UpdateCount,
Summary: localCache.Summary,
Scanners: localCache.Scanners,
Registered: h.cfg.IsRegistered(),
}
if h.desktop != nil {
running, pid := h.desktop.Status()
resp.Desktop = &DesktopStatus{
Running: running,
PID: pid,
Enabled: h.cfg.Desktop.Enabled,
}
}
writeJSON(w, resp)
}
func (h *handler) scansLatest(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, ScanResponse{
LastScanTime: localCache.LastScanTime,
LastUpdated: localCache.LastUpdated,
UpdateCount: localCache.UpdateCount,
Summary: localCache.Summary,
Scanners: localCache.Scanners,
Updates: localCache.Updates,
})
}
func (h *handler) tokensActive(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, TokenResponse{Capabilities: localCache.Capabilities})
}
// desktopHealth handles POST /v1/desktop — the desktop app reports its health.
func (h *handler) desktopHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req DesktopHealthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Log the desktop health report.
log.Printf("[INFO] [agent] [localapi] desktop_health version=%s uptime=%ds window_open=%v",
req.Version, req.Uptime, req.WindowOpen)
if h.onDesktopHealth != nil {
h.onDesktopHealth(req.Version, req.WindowOpen)
}
// Respond with agent status so the desktop app can display it.
desktopStatus := DesktopStatus{Enabled: h.cfg.Desktop.Enabled}
if h.desktop != nil {
running, pid := h.desktop.Status()
desktopStatus.Running = running
desktopStatus.PID = pid
}
writeJSON(w, map[string]interface{}{
"agent_version": version.Version,
"desktop": desktopStatus,
})
}
// triggerScanAction handles POST /v1/actions/trigger-scan — the first local
// write endpoint (FEAT-002). The OS-local group ACL on the socket/pipe is the
// authorization boundary. The scan runs through the agent's existing scanner
// primitives; this never bypasses server command-signing or package
// authorization paths because it cannot install anything.
func (h *handler) triggerScanAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.triggerScan == nil {
log.Printf("[WARNING] [agent] [localapi] scan_trigger_unavailable")
http.Error(w, "scan trigger unavailable", http.StatusServiceUnavailable)
return
}
err := h.triggerScan("localapi")
if errors.Is(err, ErrScanInFlight) {
log.Printf("[INFO] [agent] [localapi] scan_trigger_rejected reason=in_flight")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"accepted": false, "error": "scan already in flight"})
return
}
if err != nil {
log.Printf("[ERROR] [agent] [localapi] scan_trigger_failed error=%v", err)
http.Error(w, "scan trigger failed", http.StatusInternalServerError)
return
}
log.Printf("[INFO] [agent] [localapi] scan_trigger_accepted source=localapi")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSONBody(w, map[string]interface{}{"accepted": true})
}
// approveUpdateAction handles POST /v1/actions/approve-update — the standalone
// approval flow (FEAT-003). Authorization is the OS-local group ACL on the
// socket/pipe; the real judgment lives in the gates and the root-owned mint
// key. Synchronous: the connection is held until verdict + install complete.
func (h *handler) approveUpdateAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.approveUpdate == nil {
log.Printf("[WARNING] [agent] [localapi] approve_unavailable")
http.Error(w, "local approval unavailable", http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "request read failed", http.StatusBadRequest)
return
}
result, err := h.approveUpdate(body)
switch {
case err == nil:
log.Printf("[INFO] [agent] [localapi] approve_completed")
writeJSON(w, result)
case errors.Is(err, ErrApprovalConflict):
log.Printf("[SECURITY] [agent] [localapi] approve_conflict error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
case errors.Is(err, ErrApprovalUnavailable):
log.Printf("[WARNING] [agent] [localapi] approve_unavailable error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
default:
log.Printf("[ERROR] [agent] [localapi] approve_failed error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
}
}
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
localCache, err := h.loadCache()
if err != nil {
log.Printf("[ERROR] [agent] [localapi] cache_load_failed error=%v", err)
http.Error(w, "local state unavailable", http.StatusServiceUnavailable)
return nil, false
}
return localCache, true
}
func requireGet(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet {
return true
}
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return false
}
func writeJSON(w http.ResponseWriter, value interface{}) {
w.Header().Set("Content-Type", "application/json")
writeJSONBody(w, value)
}
// writeJSONBody encodes without touching headers — for callers that already
// wrote a non-200 status code.
func writeJSONBody(w http.ResponseWriter, value interface{}) {
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("[WARNING] [agent] [localapi] response_encode_failed error=%v", err)
}
}
func sortedCopy(values []string) []string {
if len(values) == 0 {
return nil
}
copied := append([]string(nil), values...)
sort.Strings(copied)
return copied
}
func groupName(opts Options) string {
if opts.GroupName != "" {
return opts.GroupName
}
return defaultGroupName()
}
func unixSocketPath(opts Options) string {
if opts.UnixSocketPath != "" {
return opts.UnixSocketPath
}
return defaultUnixSocketPath()
}
func windowsPipeName(opts Options) string {
if opts.WindowsPipeName != "" {
return opts.WindowsPipeName
}
return DefaultWindowsPipeName
}
func formatGroupMissing(name string, err error) error {
return fmt.Errorf("localapi: local access group %q unavailable: %w", name, err)
}

Some files were not shown because too many files have changed in this diff Show more