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

View file

@ -0,0 +1,494 @@
package orchestrator
import (
"crypto/ed25519"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/crypto"
"github.com/Fimeg/RedFlag/agent/internal/logging"
"github.com/gofrs/uuid/v5"
)
// ConfirmedTracker is a disk-persisted set of command IDs the server has confirmed
// as completed (via ReportLog). Used to distinguish between "command re-sent by
// server before agent confirmed" vs "command truly duplicate."
type ConfirmedTracker struct {
confirmed map[string]time.Time
mu sync.RWMutex
filePath string
}
// NewConfirmedTracker creates a tracker that persists state under statePath/confirmed_completed.json.
func NewConfirmedTracker(statePath string) *ConfirmedTracker {
return &ConfirmedTracker{
confirmed: make(map[string]time.Time),
filePath: filepath.Join(statePath, "confirmed_completed.json"),
}
}
// Load restores confirmed completions from disk. Missing file is not an error (fresh start).
func (t *ConfirmedTracker) Load() error {
t.mu.Lock()
defer t.mu.Unlock()
if _, err := os.Stat(t.filePath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(t.filePath)
if err != nil {
return fmt.Errorf("read confirmed_completed: %w", err)
}
if len(data) == 0 {
return nil
}
var loaded map[string]time.Time
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parse confirmed_completed: %w", err)
}
t.confirmed = loaded
return nil
}
// Save persists the confirmed set to disk.
func (t *ConfirmedTracker) Save() error {
t.mu.Lock()
defer t.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(t.filePath), 0o755); err != nil {
return fmt.Errorf("create confirmed dir: %w", err)
}
data, err := json.MarshalIndent(t.confirmed, "", " ")
if err != nil {
return fmt.Errorf("marshal confirmed_completed: %w", err)
}
if err := os.WriteFile(t.filePath, data, 0o600); err != nil {
return fmt.Errorf("write confirmed_completed: %w", err)
}
return nil
}
// Add records that the server confirmed a command as completed. Safe to call
// multiple times with the same ID — duplicates are a no-op (idempotent, ETHOS #4).
func (t *ConfirmedTracker) Add(commandID string) {
t.mu.Lock()
defer t.mu.Unlock()
if _, exists := t.confirmed[commandID]; exists {
return
}
t.confirmed[commandID] = time.Now().UTC()
}
// GetPending returns the current confirmed set as a slice (snapshot — safe to mutate).
func (t *ConfirmedTracker) GetPending() []string {
t.mu.RLock()
defer t.mu.RUnlock()
ids := make([]string, 0, len(t.confirmed))
for id := range t.confirmed {
ids = append(ids, id)
}
return ids
}
// Confirm removes the IDs the server confirmed. IDs not in the confirmed
// set are silently ignored — server might confirm an ID we already dropped after
// a successful prior round-trip.
func (t *ConfirmedTracker) Confirm(commandIDs []string) {
t.mu.Lock()
defer t.mu.Unlock()
for _, id := range commandIDs {
delete(t.confirmed, id)
}
}
// Len returns the current size of the confirmed set.
func (t *ConfirmedTracker) Len() int {
t.mu.RLock()
defer t.mu.RUnlock()
return len(t.confirmed)
}
const (
// keyRefreshInterval is how often the agent proactively re-checks the server's primary key
keyRefreshInterval = 6 * time.Hour
// commandMaxAge is the maximum age of a signed command (F-4 fix: reduced from 24h to 4h)
commandMaxAge = 4 * time.Hour
// commandClockSkew is the allowed future clock skew for signed commands
commandClockSkew = 5 * time.Minute
)
// CommandHandler handles command processing with signature verification
type CommandHandler struct {
verifier *crypto.CommandVerifier
securityLogger *logging.SecurityLogger
keyCache map[string]ed25519.PublicKey // key_id -> public key
keyCacheMu sync.RWMutex
executedIDs map[string]time.Time // cmd UUID -> execution time (F-2 fix: dedup)
executedIDsMu sync.Mutex
executedIDsPath string // Migration 033 §5: disk-persisted dedup
// confirmedCompleted tracks command IDs the server has confirmed as completed
// (via ReceiptConfirmedIDs/AcknowledgedIDs in response). Commands in this set
// are NOT rejected as duplicates even if they appear in the executed set,
// because the server may not have processed the ReportLog() yet.
confirmedCompleted map[string]time.Time // cmd UUID -> confirmation time
confirmedCompletedMu sync.Mutex
confirmedCompletedPath string // disk-persisted confirmation set
lastKeyRefresh time.Time
logger *log.Logger
}
// CommandSigningConfig holds configuration for command signing
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"`
}
// NewCommandHandler creates a new command handler. stateDir is the agent's persistent
// state directory; passing an empty string disables disk-persisted dedup (test path).
func NewCommandHandler(cfg *config.Config, stateDir string, securityLogger *logging.SecurityLogger, logger *log.Logger) (*CommandHandler, error) {
handler := &CommandHandler{
securityLogger: securityLogger,
logger: logger,
verifier: crypto.NewCommandVerifier(),
keyCache: make(map[string]ed25519.PublicKey),
executedIDs: make(map[string]time.Time),
confirmedCompleted: make(map[string]time.Time),
confirmedCompletedMu: sync.Mutex{},
confirmedCompletedPath: "",
}
if stateDir != "" {
handler.executedIDsPath = filepath.Join(stateDir, "executed_commands.json")
handler.confirmedCompletedPath = filepath.Join(stateDir, "confirmed_completed.json")
}
// Migration 033 §5: rebuild dedup set from disk so a restart can't re-execute a
// command issued within commandMaxAge. Missing file is a fresh start, not an error.
if handler.executedIDsPath != "" {
if err := handler.loadExecutedIDs(); err != nil {
logger.Printf("[WARNING] [agent] [cmd_handler] load_executed_ids_failed path=%q error=%v", handler.executedIDsPath, err)
} else {
logger.Printf("[INFO] [agent] [cmd_handler] executed_ids_loaded count=%d path=%q", len(handler.executedIDs), handler.executedIDsPath)
// Trim anything that's already aged out — keeps the file from growing forever.
handler.CleanupExecutedIDs()
}
}
// Load confirmed completion tracker (server-acked commands)
if handler.confirmedCompletedPath != "" {
if err := handler.loadConfirmedCompleted(); err != nil {
logger.Printf("[WARNING] [agent] [cmd_handler] load_confirmed_completed_failed path=%q error=%v", handler.confirmedCompletedPath, err)
} else {
logger.Printf("[INFO] [agent] [cmd_handler] confirmed_completed_loaded count=%d", len(handler.confirmedCompleted))
}
}
// Pre-load cached public key if command signing is enabled
if cfg.CommandSigning.Enabled {
if pubKey, err := crypto.LoadCachedPublicKey(); err == nil {
// Store under empty key_id for backward-compat lookup
handler.keyCacheMu.Lock()
handler.keyCache[""] = pubKey
handler.keyCacheMu.Unlock()
logger.Printf("[INFO] [agent] [cmd_handler] primary_public_key_loaded")
} else {
logger.Printf("[WARNING] [agent] [cmd_handler] primary_key_not_cached error=\"%v\"", err)
}
}
return handler, nil
}
// loadExecutedIDs restores the dedup set from disk. Caller holds no lock.
func (h *CommandHandler) loadExecutedIDs() error {
if _, err := os.Stat(h.executedIDsPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(h.executedIDsPath)
if err != nil {
return fmt.Errorf("read: %w", err)
}
if len(data) == 0 {
return nil
}
var loaded map[string]time.Time
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parse: %w", err)
}
h.executedIDsMu.Lock()
h.executedIDs = loaded
h.executedIDsMu.Unlock()
return nil
}
// loadConfirmedCompleted restores the server-confirmed completion set from disk.
// Called once during construction. Caller holds no lock.
func (h *CommandHandler) loadConfirmedCompleted() error {
if _, err := os.Stat(h.confirmedCompletedPath); os.IsNotExist(err) {
return nil
}
data, err := os.ReadFile(h.confirmedCompletedPath)
if err != nil {
return fmt.Errorf("read confirmed_completed: %w", err)
}
if len(data) == 0 {
return nil
}
var loaded map[string]time.Time
if err := json.Unmarshal(data, &loaded); err != nil {
return fmt.Errorf("parse confirmed_completed: %w", err)
}
h.confirmedCompletedMu.Lock()
h.confirmedCompleted = loaded
h.confirmedCompletedMu.Unlock()
return nil
}
// saveConfirmedCompleted persists the server-confirmed completion set to disk.
// Caller MUST hold confirmedCompletedMu.
func (h *CommandHandler) saveConfirmedCompletedLocked() error {
if h.confirmedCompletedPath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(h.confirmedCompletedPath), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
data, err := json.MarshalIndent(h.confirmedCompleted, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp := h.confirmedCompletedPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write: %w", err)
}
if err := os.Rename(tmp, h.confirmedCompletedPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// saveExecutedIDs persists the current dedup set. Caller MUST hold executedIDsMu.
// Errors are logged by the caller — best-effort persistence; the in-memory set is
// the authoritative source within a single process lifetime.
func (h *CommandHandler) saveExecutedIDsLocked() error {
if h.executedIDsPath == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(h.executedIDsPath), 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
data, err := json.MarshalIndent(h.executedIDs, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
tmp := h.executedIDsPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write: %w", err)
}
if err := os.Rename(tmp, h.executedIDsPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// getKeyForCommand returns the appropriate public key for verifying a command.
// Uses key_id-aware lookup with lazy fetch for unknown keys.
func (h *CommandHandler) getKeyForCommand(cmd client.Command, serverURL string) (ed25519.PublicKey, error) {
keyID := cmd.KeyID
// Check in-memory cache first
h.keyCacheMu.RLock()
if key, ok := h.keyCache[keyID]; ok {
h.keyCacheMu.RUnlock()
return key, nil
}
h.keyCacheMu.RUnlock()
// Not in memory — check disk cache via CheckKeyRotation
key, isNew, err := h.verifier.CheckKeyRotation(keyID, serverURL)
if err != nil {
return nil, fmt.Errorf("failed to resolve key %q: %w", keyID, err)
}
if isNew {
h.logger.Printf("[INFO] [agent] [cmd_handler] new_signing_key_cached key_id=%q", keyID)
if h.securityLogger != nil {
h.securityLogger.LogKeyRotationDetected(keyID)
}
}
// Store in memory cache
h.keyCacheMu.Lock()
h.keyCache[keyID] = key
h.keyCacheMu.Unlock()
return key, nil
}
// ProcessCommand processes a command with signature verification
func (h *CommandHandler) ProcessCommand(cmd client.Command, cfg *config.Config, agentID uuid.UUID) error {
// F-2 fix: Check deduplication BEFORE verification.
// Persistence is implemented (see loadExecutedIDs/saveExecutedIDsLocked, Migration 033 §5):
// the set survives restarts within commandMaxAge.
h.executedIDsMu.Lock()
if execTime, found := h.executedIDs[cmd.ID]; found {
h.executedIDsMu.Unlock()
h.logger.Printf("[WARNING] [agent] [cmd_handler] duplicate_command_rejected command_id=%q already_executed_at=%v", cmd.ID, execTime)
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationFailure(cmd.ID, fmt.Sprintf("duplicate command rejected, already executed at %v", execTime))
}
return fmt.Errorf("duplicate command %s rejected, already executed at %v", cmd.ID, execTime)
}
h.executedIDsMu.Unlock()
signingCfg := cfg.CommandSigning
if !signingCfg.Enabled {
if cmd.Signature != "" {
h.logger.Printf("[INFO] [agent] [cmd_handler] command_has_signature_but_signing_disabled command_id=%q", cmd.ID)
}
h.markExecuted(cmd.ID)
return nil
}
// Resolve the correct public key for this command
pubKey, err := h.getKeyForCommand(cmd, cfg.ServerURL)
if err != nil {
h.logger.Printf("[ERROR] [agent] [cmd_handler] key_resolution_failed command_id=%q error=%q", cmd.ID, err)
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationFailure(cmd.ID, "key resolution failed: "+err.Error())
}
if signingCfg.EnforcementMode == "strict" {
return fmt.Errorf("command verification failed: %w", err)
}
return nil
}
verifyFunc := func() error {
if cmd.SignedAt != nil {
// New format: timestamp-aware verification
return h.verifier.VerifyCommandWithTimestamp(cmd, pubKey, commandMaxAge, commandClockSkew)
}
// Old format: no timestamp (backward compat)
return h.verifier.VerifyCommand(cmd, pubKey)
}
switch signingCfg.EnforcementMode {
case "strict":
if cmd.Signature == "" {
h.logger.Printf("[ERROR] [agent] [cmd_handler] command_not_signed command_id=%q", cmd.ID)
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationFailure(cmd.ID, "missing signature")
}
return fmt.Errorf("command verification failed: strict enforcement requires signed commands")
}
if err := verifyFunc(); err != nil {
h.logger.Printf("[ERROR] [agent] [cmd_handler] command_verification_failed command_id=%q error=%q", cmd.ID, err)
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationFailure(cmd.ID, err.Error())
}
return fmt.Errorf("command verification failed: %w", err)
}
h.logger.Printf("[INFO] [agent] [cmd_handler] command_verified command_id=%q", cmd.ID)
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationSuccess(cmd.ID)
}
h.markExecuted(cmd.ID)
case "warning":
if cmd.Signature != "" {
if err := verifyFunc(); err != nil {
h.logger.Printf("[WARNING] [agent] [cmd_handler] verification_failed_warning_mode command_id=%q error=%q", cmd.ID, err)
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationFailure(cmd.ID, err.Error())
}
} else {
if h.securityLogger != nil {
h.securityLogger.LogCommandVerificationSuccess(cmd.ID)
}
}
} else {
h.logger.Printf("[WARNING] [agent] [cmd_handler] unsigned_command_warning_mode command_id=%q", cmd.ID)
}
h.markExecuted(cmd.ID)
// "disabled" or any other value: skip verification
default:
h.markExecuted(cmd.ID)
}
return nil
}
// markExecuted records a command ID in the deduplication set (F-2 fix).
// Persists the updated set to disk so a restart-within-commandMaxAge cannot re-execute
// the same command (Migration 033 §5).
func (h *CommandHandler) markExecuted(cmdID string) {
h.executedIDsMu.Lock()
h.executedIDs[cmdID] = time.Now().UTC()
if err := h.saveExecutedIDsLocked(); err != nil {
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_executed_ids_failed cmd=%s error=%v", cmdID, err)
}
h.executedIDsMu.Unlock()
}
// CleanupExecutedIDs evicts entries older than commandMaxAge from the dedup set.
// Should be called when ShouldRefreshKey() fires (every 6h). Persists the trimmed
// set if anything was evicted.
func (h *CommandHandler) CleanupExecutedIDs() {
h.executedIDsMu.Lock()
defer h.executedIDsMu.Unlock()
cutoff := time.Now().UTC().Add(-commandMaxAge)
evicted := 0
for id, execTime := range h.executedIDs {
if execTime.Before(cutoff) {
delete(h.executedIDs, id)
evicted++
}
}
if evicted > 0 {
h.logger.Printf("[INFO] [agent] [cmd_handler] cleanup_executed_ids evicted=%d remaining=%d", evicted, len(h.executedIDs))
if err := h.saveExecutedIDsLocked(); err != nil {
h.logger.Printf("[ERROR] [agent] [cmd_handler] persist_after_cleanup_failed error=%v", err)
}
}
}
// RefreshPrimaryKey proactively re-fetches the server's primary key.
// Should be called every keyRefreshInterval to detect rotations early.
func (h *CommandHandler) RefreshPrimaryKey(serverURL string) error {
h.logger.Printf("[INFO] [agent] [cmd_handler] refreshing_primary_key")
pubKey, err := crypto.FetchAndCacheServerPublicKey(serverURL)
if err != nil {
return fmt.Errorf("failed to refresh primary key: %w", err)
}
h.keyCacheMu.Lock()
h.keyCache[""] = pubKey
h.keyCacheMu.Unlock()
h.lastKeyRefresh = time.Now()
h.logger.Printf("[INFO] [agent] [cmd_handler] primary_key_refreshed")
return nil
}
// ShouldRefreshKey returns true if enough time has passed to warrant a proactive key refresh
func (h *CommandHandler) ShouldRefreshKey() bool {
return time.Since(h.lastKeyRefresh) >= keyRefreshInterval
}
// UpdateServerPublicKey updates the primary cached public key (kept for backward compat)
func (h *CommandHandler) UpdateServerPublicKey(serverURL string) error {
return h.RefreshPrimaryKey(serverURL)
}

View file

@ -0,0 +1,575 @@
package orchestrator
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"strings"
"sync"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
)
// DockerScanner scans for Docker image updates
type DockerScanner struct {
client *dockerclient.Client
registryClient *RegistryClient
}
// NewDockerScanner creates a new Docker scanner
func NewDockerScanner() (*DockerScanner, error) {
cli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return nil, err
}
return &DockerScanner{
client: cli,
registryClient: NewRegistryClient(),
}, nil
}
// IsAvailable checks if Docker is available on this system
func (s *DockerScanner) IsAvailable() bool {
_, err := exec.LookPath("docker")
if err != nil {
return false
}
if s.client != nil {
_, err := s.client.Ping(context.Background())
return err == nil
}
return false
}
// Scan collects Docker image info and returns UpdateReportItems with full data in Metadata
func (s *DockerScanner) Scan() ([]client.UpdateReportItem, error) {
ctx := context.Background()
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var items []client.UpdateReportItem
seenImages := make(map[string]bool)
for _, c := range containers {
imageName := c.Image
if seenImages[imageName] {
continue
}
seenImages[imageName] = true
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
if err != nil {
continue
}
parts := strings.Split(imageName, ":")
baseImage := parts[0]
currentTag := "latest"
if len(parts) > 1 {
currentTag = parts[1]
}
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
localDigest := imageInspect.ID
localShortDigest := ""
if len(localDigest) > 7 {
parts := strings.SplitN(localDigest, ":", 2)
if len(parts) == 2 && len(parts[1]) >= 12 {
localShortDigest = parts[1][:12]
}
}
remoteShortDigest := ""
if len(remoteDigest) > 7 {
parts := strings.SplitN(remoteDigest, ":", 2)
if len(parts) == 2 && len(parts[1]) >= 12 {
remoteShortDigest = parts[1][:12]
}
}
severity := "low"
if hasUpdate {
severity = "moderate"
}
labels := make(map[string]string)
if imageInspect.Config != nil {
labels = imageInspect.Config.Labels
}
sizeBytes := int64(0)
if len(imageInspect.RootFS.Layers) > 0 {
sizeBytes = imageInspect.Size
}
createdAt := imageInspect.Created
if createdAt == "" {
createdAt = time.Now().UTC().Format(time.RFC3339)
}
items = append(items, client.UpdateReportItem{
PackageType: "docker_image",
PackageName: imageName,
PackageDescription: fmt.Sprintf("Docker image %s:%s", imageName, currentTag),
CurrentVersion: localShortDigest,
AvailableVersion: remoteShortDigest,
Severity: severity,
RepositorySource: baseImage,
SizeBytes: sizeBytes,
Metadata: map[string]interface{}{
"image_name": imageName,
"image_tag": currentTag,
"image_id": localShortDigest,
"latest_image_id": remoteShortDigest,
"has_update": hasUpdate,
"repository": baseImage,
"size_bytes": sizeBytes,
"created_at": createdAt,
"labels": labels,
"container_id": c.ID[:12],
"container_names": c.Names,
"container_state": c.State,
"image_created": imageInspect.Created,
"local_full_digest": localDigest,
"remote_digest": remoteDigest,
},
})
}
return items, nil
}
// Name returns the scanner name
func (s *DockerScanner) Name() string {
return "Docker Image Scanner"
}
// ScanContainers returns container-level data for enriched Docker reports.
func (s *DockerScanner) ScanContainers() ([]client.DockerReportContainer, error) {
ctx := context.Background()
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var result []client.DockerReportContainer
for _, c := range containers {
stackName := ""
if v, ok := c.Labels["com.docker.stack.namespace"]; ok {
stackName = v
}
if v, ok := c.Labels["com.docker.compose.project"]; ok && stackName == "" {
stackName = v
}
health := ""
if c.State == "running" {
inspect, err := s.client.ContainerInspect(ctx, c.ID)
if err == nil && inspect.State != nil && inspect.State.Health != nil {
health = inspect.State.Health.Status
}
}
name := ""
if len(c.Names) > 0 {
name = strings.TrimPrefix(c.Names[0], "/")
}
ports := ""
for _, p := range c.Ports {
if p.PublicPort > 0 {
ports += fmt.Sprintf("%s:%d->%d/%s ", p.IP, p.PublicPort, p.PrivatePort, p.Type)
}
}
result = append(result, client.DockerReportContainer{
ContainerID: c.ID[:12],
Name: name,
Image: c.Image,
ImageID: c.ImageID,
State: c.State,
Health: health,
StackName: stackName,
Ports: strings.TrimSpace(ports),
CreatedAt: c.Created,
Labels: c.Labels,
})
}
return result, nil
}
// ScanStacks aggregates containers into compose stacks.
func (s *DockerScanner) ScanStacks(containers []client.DockerReportContainer) []client.DockerReportStack {
stackMap := make(map[string]*client.DockerReportStack)
for _, c := range containers {
if c.StackName == "" {
continue
}
s, ok := stackMap[c.StackName]
if !ok {
s = &client.DockerReportStack{Name: c.StackName}
stackMap[c.StackName] = s
}
s.ContainerCount++
if c.State == "running" {
s.RunningCount++
}
}
result := make([]client.DockerReportStack, 0, len(stackMap))
for _, s := range stackMap {
result = append(result, *s)
}
return result
}
// GetEngineVersion returns the Docker engine version string.
func (s *DockerScanner) GetEngineVersion() string {
ctx := context.Background()
v, err := s.client.ServerVersion(ctx)
if err != nil {
return ""
}
return v.Version
}
// Close closes the Docker client
func (s *DockerScanner) Close() error {
if s.client != nil {
return s.client.Close()
}
return nil
}
// checkForUpdate checks if a newer image version is available by comparing digests
func (s *DockerScanner) checkForUpdate(ctx context.Context, imageName, tag, currentID string) (bool, string) {
remoteDigest, err := s.registryClient.GetRemoteDigest(ctx, imageName, tag)
if err != nil {
log.Printf("[WARNING] [agent] [docker] registry_check_failed image=%s:%s error=%v", imageName, tag, err)
return false, ""
}
return currentID != remoteDigest, remoteDigest
}
// --- Registry Client ---
// RegistryClient handles communication with Docker registries (Docker Hub and custom registries)
type RegistryClient struct {
httpClient *http.Client
cache *manifestCache
}
// manifestCache stores registry responses to avoid hitting rate limits
type manifestCache struct {
mu sync.RWMutex
entries map[string]*cacheEntry
}
type cacheEntry struct {
digest string
expiresAt time.Time
}
// ManifestResponse represents the response from a Docker Registry API v2 manifest request
type ManifestResponse struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config struct {
Digest string `json:"digest"`
} `json:"config"`
}
// DockerHubTokenResponse represents the authentication token response from Docker Hub
type DockerHubTokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
IssuedAt time.Time `json:"issued_at"`
}
// NewRegistryClient creates a new registry client with caching
func NewRegistryClient() *RegistryClient {
return &RegistryClient{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
cache: &manifestCache{
entries: make(map[string]*cacheEntry),
},
}
}
// GetRemoteDigest fetches the digest of a remote image from the registry
// Returns the digest string (e.g., "sha256:abc123...") or an error
func (c *RegistryClient) GetRemoteDigest(ctx context.Context, imageName, tag string) (string, error) {
registry, repository := parseImageName(imageName)
cacheKey := fmt.Sprintf("%s/%s:%s", registry, repository, tag)
if digest := c.cache.get(cacheKey); digest != "" {
return digest, nil
}
token, err := c.getAuthToken(ctx, registry, repository)
if err != nil {
return "", fmt.Errorf("failed to get auth token: %w", err)
}
digest, err := c.fetchManifestDigest(ctx, registry, repository, tag, token)
if err != nil {
return "", fmt.Errorf("failed to fetch manifest: %w", err)
}
c.cache.set(cacheKey, digest, 5*time.Minute)
return digest, nil
}
// parseImageName splits an image name into registry and repository
// Examples:
// - "nginx" -> ("registry-1.docker.io", "library/nginx")
// - "myuser/myimage" -> ("registry-1.docker.io", "myuser/myimage")
// - "gcr.io/myproject/myimage" -> ("gcr.io", "myproject/myimage")
func parseImageName(imageName string) (registry, repository string) {
parts := strings.Split(imageName, "/")
if len(parts) >= 2 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
registry = parts[0]
repository = strings.Join(parts[1:], "/")
} else if len(parts) == 1 {
registry = "registry-1.docker.io"
repository = "library/" + parts[0]
} else {
registry = "registry-1.docker.io"
repository = imageName
}
return registry, repository
}
// getAuthToken obtains an authentication token for the registry
func (c *RegistryClient) getAuthToken(ctx context.Context, registry, repository string) (string, error) {
if registry == "registry-1.docker.io" {
return c.getDockerHubToken(ctx, repository)
}
return "", nil
}
// getDockerHubToken obtains a token from Docker Hub's authentication service
func (c *RegistryClient) getDockerHubToken(ctx context.Context, repository string) (string, error) {
authURL := fmt.Sprintf(
"https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull",
repository,
)
req, err := http.NewRequestWithContext(ctx, "GET", authURL, nil)
if err != nil {
return "", err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp DockerHubTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
}
if tokenResp.Token != "" {
return tokenResp.Token, nil
}
return tokenResp.AccessToken, nil
}
// fetchManifestDigest fetches the manifest from the registry and extracts the digest
func (c *RegistryClient) fetchManifestDigest(ctx context.Context, registry, repository, tag, token string) (string, error) {
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registry, repository, tag)
req, err := http.NewRequestWithContext(ctx, "GET", manifestURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return "", fmt.Errorf("rate limited by registry (429 Too Many Requests)")
}
if resp.StatusCode == http.StatusUnauthorized {
return "", fmt.Errorf("unauthorized: authentication failed for %s/%s:%s", registry, repository, tag)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("manifest request failed with status %d: %s", resp.StatusCode, string(body))
}
if digest := resp.Header.Get("Docker-Content-Digest"); digest != "" {
return digest, nil
}
var manifest ManifestResponse
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
return "", fmt.Errorf("failed to decode manifest: %w", err)
}
if manifest.Config.Digest == "" {
return "", fmt.Errorf("manifest does not contain a config digest")
}
return manifest.Config.Digest, nil
}
// manifestCache methods
func (mc *manifestCache) get(key string) string {
mc.mu.RLock()
defer mc.mu.RUnlock()
entry, exists := mc.entries[key]
if !exists {
return ""
}
if time.Now().After(entry.expiresAt) {
delete(mc.entries, key)
return ""
}
return entry.digest
}
func (mc *manifestCache) set(key, digest string, ttl time.Duration) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.entries[key] = &cacheEntry{
digest: digest,
expiresAt: time.Now().Add(ttl),
}
}
// ScanInventory returns Docker images as InventoryItems. Implements the
// InventoryScanner interface. This is the inventory path — distinct from
// Scan() which returns UpdateReportItems for the update path.
func (s *DockerScanner) ScanInventory() ([]client.InventoryItem, error) {
ctx := context.Background()
containers, err := s.client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var items []client.InventoryItem
seenImages := make(map[string]bool)
for _, c := range containers {
imageName := c.Image
if seenImages[imageName] {
continue
}
seenImages[imageName] = true
imageInspect, _, err := s.client.ImageInspectWithRaw(ctx, imageName)
if err != nil {
continue
}
parts := strings.Split(imageName, ":")
baseImage := parts[0]
currentTag := "latest"
if len(parts) > 1 {
currentTag = parts[1]
}
localDigest := imageInspect.ID
localShortDigest := ""
if len(localDigest) > 7 {
digestParts := strings.SplitN(localDigest, ":", 2)
if len(digestParts) == 2 && len(digestParts[1]) >= 12 {
localShortDigest = digestParts[1][:12]
}
}
hasUpdate, remoteDigest := s.checkForUpdate(ctx, baseImage, currentTag, imageInspect.ID)
remoteShortDigest := ""
if len(remoteDigest) > 7 {
digestParts := strings.SplitN(remoteDigest, ":", 2)
if len(digestParts) == 2 && len(digestParts[1]) >= 12 {
remoteShortDigest = digestParts[1][:12]
}
}
labels := make(map[string]string)
if imageInspect.Config != nil {
labels = imageInspect.Config.Labels
}
sizeBytes := imageInspect.Size
createdAt := imageInspect.Created
if createdAt == "" {
createdAt = time.Now().UTC().Format(time.RFC3339)
}
items = append(items, client.InventoryItem{
Ecosystem: "docker",
ItemName: imageName,
ItemVersion: localShortDigest,
Description: fmt.Sprintf("Docker image %s:%s", baseImage, currentTag),
SizeBytes: sizeBytes,
Vendor: baseImage,
Metadata: map[string]interface{}{
"image_tag": currentTag,
"image_id": localShortDigest,
"latest_image_id": remoteShortDigest,
"has_update": hasUpdate,
"repository": baseImage,
"created_at": createdAt,
"labels": labels,
"container_id": c.ID[:12],
"container_names": c.Names,
"container_state": c.State,
"local_full_digest": localDigest,
"remote_digest": remoteDigest,
},
})
}
return items, nil
}

View file

@ -0,0 +1,410 @@
package orchestrator
import (
"context"
"fmt"
"log"
"sync"
"time"
"github.com/Fimeg/RedFlag/agent/internal/circuitbreaker"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/event"
)
// Scanner represents a generic update scanner
type Scanner interface {
// IsAvailable checks if the scanner is available on this system
IsAvailable() bool
// Scan performs the actual scanning and returns update items
Scan() ([]client.UpdateReportItem, error)
// Name returns the scanner name for logging
Name() string
}
// InventoryScanner scans for installed software, container images, and other
// inventory data. Distinct from Scanner which scans for available updates.
type InventoryScanner interface {
IsAvailable() bool
ScanInventory() ([]client.InventoryItem, error)
Name() string
}
// InventoryScannerConfig holds configuration for a single inventory scanner.
type InventoryScannerConfig struct {
Scanner InventoryScanner
CircuitBreaker *circuitbreaker.CircuitBreaker
Timeout time.Duration
Enabled bool
}
// InventoryScanResult holds the result of an inventory scanner execution.
type InventoryScanResult struct {
ScannerName string
Items []client.InventoryItem
Error error
Duration time.Duration
Status string // "success", "failed", "disabled", "unavailable", "skipped"
}
// ScannerConfig holds configuration for a single scanner
type ScannerConfig struct {
Scanner Scanner
CircuitBreaker *circuitbreaker.CircuitBreaker
Timeout time.Duration
Enabled bool
}
// ScanResult holds the result of a scanner execution
type ScanResult struct {
ScannerName string
Updates []client.UpdateReportItem
Error error
Duration time.Duration
Status string // "success", "failed", "disabled", "unavailable", "skipped"
}
// Orchestrator manages and coordinates multiple scanners
type Orchestrator struct {
scanners map[string]*ScannerConfig
inventoryScanners map[string]*InventoryScannerConfig
logger *event.TeeLogger
mu sync.RWMutex
}
// NewOrchestratorWithEvents creates a new scanner orchestrator with event buffering
func NewOrchestratorWithEvents(logger *event.TeeLogger) *Orchestrator {
return &Orchestrator{
scanners: make(map[string]*ScannerConfig),
inventoryScanners: make(map[string]*InventoryScannerConfig),
logger: logger,
}
}
// RegisterScanner adds a scanner to the orchestrator
func (o *Orchestrator) RegisterScanner(name string, scanner Scanner, cb *circuitbreaker.CircuitBreaker, timeout time.Duration, enabled bool) {
o.mu.Lock()
defer o.mu.Unlock()
o.scanners[name] = &ScannerConfig{
Scanner: scanner,
CircuitBreaker: cb,
Timeout: timeout,
Enabled: enabled,
}
}
// RegisterInventoryScanner adds an inventory scanner to the orchestrator.
func (o *Orchestrator) RegisterInventoryScanner(name string, scanner InventoryScanner, cb *circuitbreaker.CircuitBreaker, timeout time.Duration, enabled bool) {
o.mu.Lock()
defer o.mu.Unlock()
o.inventoryScanners[name] = &InventoryScannerConfig{
Scanner: scanner,
CircuitBreaker: cb,
Timeout: timeout,
Enabled: enabled,
}
}
// ScanInventorySingle executes a single inventory scanner by name.
func (o *Orchestrator) ScanInventorySingle(ctx context.Context, scannerName string) (InventoryScanResult, error) {
o.mu.RLock()
defer o.mu.RUnlock()
cfg, exists := o.inventoryScanners[scannerName]
if !exists {
return InventoryScanResult{
ScannerName: scannerName,
Status: "failed",
Error: fmt.Errorf("inventory scanner not found: %s", scannerName),
}, fmt.Errorf("inventory scanner not found: %s", scannerName)
}
return o.executeInventoryScan(ctx, scannerName, cfg), nil
}
// executeInventoryScan runs a single inventory scanner with circuit breaker and timeout protection.
func (o *Orchestrator) executeInventoryScan(ctx context.Context, name string, cfg *InventoryScannerConfig) InventoryScanResult {
result := InventoryScanResult{
ScannerName: name,
Status: "failed",
}
startTime := time.Now()
defer func() {
result.Duration = time.Since(startTime)
}()
if !cfg.Enabled {
result.Status = "disabled"
log.Printf("[%s] Inventory scanner disabled via configuration", name)
return result
}
if !cfg.Scanner.IsAvailable() {
result.Status = "unavailable"
log.Printf("[%s] Inventory scanner not available on this system", name)
return result
}
log.Printf("[%s] Starting inventory scan...", name)
var items []client.InventoryItem
err := cfg.CircuitBreaker.Call(func() error {
timeoutCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
type scanResult struct {
items []client.InventoryItem
err error
}
scanChan := make(chan scanResult, 1)
go func() {
i, e := cfg.Scanner.ScanInventory()
scanChan <- scanResult{items: i, err: e}
}()
select {
case <-timeoutCtx.Done():
return fmt.Errorf("inventory scan timeout after %v", cfg.Timeout)
case res := <-scanChan:
if res.err != nil {
return res.err
}
items = res.items
return nil
}
})
if err != nil {
result.Error = err
result.Status = "failed"
log.Printf("[%s] Inventory scan failed: %v", name, err)
return result
}
result.Items = items
result.Status = "success"
log.Printf("[%s] Inventory scan completed: found %d items (took %v)", name, len(items), result.Duration)
return result
}
// ScanAll executes all registered scanners in parallel
func (o *Orchestrator) ScanAll(ctx context.Context) ([]ScanResult, []client.UpdateReportItem) {
o.mu.RLock()
defer o.mu.RUnlock()
var wg sync.WaitGroup
resultsChan := make(chan ScanResult, len(o.scanners))
// Launch goroutine for each scanner
for name, scannerConfig := range o.scanners {
wg.Add(1)
go func(name string, cfg *ScannerConfig) {
defer wg.Done()
result := o.executeScan(ctx, name, cfg)
resultsChan <- result
}(name, scannerConfig)
}
// Wait for all scanners to complete
wg.Wait()
close(resultsChan)
// Collect results
var results []ScanResult
var allUpdates []client.UpdateReportItem
for result := range resultsChan {
results = append(results, result)
if result.Error == nil && len(result.Updates) > 0 {
allUpdates = append(allUpdates, result.Updates...)
}
}
return results, allUpdates
}
// ScanSingle executes a single scanner by name
func (o *Orchestrator) ScanSingle(ctx context.Context, scannerName string) (ScanResult, error) {
o.mu.RLock()
defer o.mu.RUnlock()
cfg, exists := o.scanners[scannerName]
if !exists {
return ScanResult{
ScannerName: scannerName,
Status: "failed",
Error: fmt.Errorf("scanner not found: %s", scannerName),
}, fmt.Errorf("scanner not found: %s", scannerName)
}
return o.executeScan(ctx, scannerName, cfg), nil
}
// executeScan runs a single scanner with circuit breaker and timeout protection
func (o *Orchestrator) executeScan(ctx context.Context, name string, cfg *ScannerConfig) ScanResult {
result := ScanResult{
ScannerName: name,
Status: "failed",
}
startTime := time.Now()
defer func() {
result.Duration = time.Since(startTime)
}()
// Check if enabled
if !cfg.Enabled {
result.Status = "disabled"
o.logger.Info("agent", "orchestrator", "scanner", "scanner disabled", map[string]interface{}{
"scanner_name": name,
"status": "disabled",
"reason": "configuration",
})
return result
}
// Check if available
if !cfg.Scanner.IsAvailable() {
result.Status = "unavailable"
o.logger.Info("agent", "orchestrator", "scanner", "scanner unavailable", map[string]interface{}{
"scanner_name": name,
"status": "unavailable",
"reason": "system_incompatible",
})
return result
}
// Execute with circuit breaker and timeout
o.logger.Info("agent", "orchestrator", "scanner", "starting scan", map[string]interface{}{
"scanner_name": name,
})
var updates []client.UpdateReportItem
err := cfg.CircuitBreaker.Call(func() error {
// Create timeout context
timeoutCtx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
// Channel for scan result
type scanResult struct {
updates []client.UpdateReportItem
err error
}
scanChan := make(chan scanResult, 1)
// Run scan in goroutine
go func() {
u, e := cfg.Scanner.Scan()
scanChan <- scanResult{updates: u, err: e}
}()
// Wait for scan or timeout
select {
case <-timeoutCtx.Done():
return fmt.Errorf("scan timeout after %v", cfg.Timeout)
case res := <-scanChan:
if res.err != nil {
return res.err
}
updates = res.updates
return nil
}
})
if err != nil {
result.Error = err
result.Status = "failed"
o.logger.Error("agent", "orchestrator", "scanner", "scan failed", map[string]interface{}{
"scanner_name": name,
"error_type": "scan_failed",
"error_details": err.Error(),
"duration_ms": result.Duration.Milliseconds(),
})
return result
}
result.Updates = updates
result.Status = "success"
o.logger.Info("agent", "orchestrator", "scanner", "scan completed", map[string]interface{}{
"scanner_name": name,
"updates_found": len(updates),
"duration_ms": result.Duration.Milliseconds(),
"status": "success",
})
return result
}
// GetScannerNames returns a list of all registered scanner names
func (o *Orchestrator) GetScannerNames() []string {
o.mu.RLock()
defer o.mu.RUnlock()
names := make([]string, 0, len(o.scanners))
for name := range o.scanners {
names = append(names, name)
}
return names
}
// FormatScanSummary creates a human-readable summary of scan results
func FormatScanSummary(results []ScanResult) (stdout string, stderr string, exitCode int) {
var successResults []string
var errorMessages []string
totalUpdates := 0
for _, result := range results {
switch result.Status {
case "success":
msg := fmt.Sprintf("%s: Found %d updates (%.2fs)",
result.ScannerName, len(result.Updates), result.Duration.Seconds())
successResults = append(successResults, msg)
totalUpdates += len(result.Updates)
case "failed":
msg := fmt.Sprintf("%s: %v", result.ScannerName, result.Error)
errorMessages = append(errorMessages, msg)
case "disabled":
successResults = append(successResults, fmt.Sprintf("%s: Disabled", result.ScannerName))
case "unavailable":
successResults = append(successResults, fmt.Sprintf("%s: Not available", result.ScannerName))
}
}
// Build stdout
if len(successResults) > 0 {
stdout = "Scan Results:\n"
for _, msg := range successResults {
stdout += fmt.Sprintf(" - %s\n", msg)
}
stdout += fmt.Sprintf("\nTotal Updates Found: %d\n", totalUpdates)
}
// Build stderr
if len(errorMessages) > 0 {
stderr = "Scan Errors:\n"
for _, msg := range errorMessages {
stderr += fmt.Sprintf(" - %s\n", msg)
}
}
// Determine exit code
if len(errorMessages) > 0 {
exitCode = 1
} else {
exitCode = 0
}
return stdout, stderr, exitCode
}

View file

@ -0,0 +1,86 @@
package orchestrator
import (
"fmt"
"time"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/system"
)
// StorageScanner scans disk usage metrics
type StorageScanner struct {
agentVersion string
}
// NewStorageScanner creates a new storage scanner
func NewStorageScanner(agentVersion string) *StorageScanner {
return &StorageScanner{
agentVersion: agentVersion,
}
}
// IsAvailable always returns true since storage scanning is always available
func (s *StorageScanner) IsAvailable() bool {
return true
}
// Scan collects disk usage and returns UpdateReportItems with full data in Metadata
func (s *StorageScanner) Scan() ([]client.UpdateReportItem, error) {
sysInfo, err := system.GetSystemInfo(s.agentVersion)
if err != nil {
return nil, fmt.Errorf("failed to get system info: %w", err)
}
if len(sysInfo.DiskInfo) == 0 {
return nil, fmt.Errorf("no disk information available")
}
var items []client.UpdateReportItem
for _, disk := range sysInfo.DiskInfo {
severity := "low"
switch {
case disk.UsedPercent >= 95:
severity = "critical"
case disk.UsedPercent >= 90:
severity = "important"
case disk.UsedPercent >= 80:
severity = "moderate"
}
item := client.UpdateReportItem{
PackageType: "storage",
PackageName: disk.Mountpoint,
PackageDescription: fmt.Sprintf("Storage metrics for %s (%s)", disk.Mountpoint, disk.Filesystem),
CurrentVersion: fmt.Sprintf("%.1f%% used", disk.UsedPercent),
AvailableVersion: fmt.Sprintf("%.1f GB free", float64(disk.Available)/1024/1024/1024),
Severity: severity,
RepositorySource: disk.Device,
SizeBytes: int64(disk.Total),
Metadata: map[string]interface{}{
"mountpoint": disk.Mountpoint,
"device": disk.Device,
"disk_type": disk.DiskType,
"filesystem": disk.Filesystem,
"total_bytes": int64(disk.Total),
"used_bytes": int64(disk.Used),
"available_bytes": int64(disk.Available),
"used_percent": disk.UsedPercent,
"is_root": disk.IsRoot,
"is_largest": disk.IsLargest,
"severity": severity,
"agent_version": s.agentVersion,
"collected_at": time.Now().UTC().Format(time.RFC3339),
},
}
items = append(items, item)
}
return items, nil
}
// Name returns the scanner name
func (s *StorageScanner) Name() string {
return "Disk Usage Reporter"
}

View file

@ -0,0 +1,139 @@
package orchestrator
import (
"fmt"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/system"
)
// SystemScanner scans system metrics (CPU, memory, processes, uptime)
type SystemScanner struct {
agentVersion string
}
// NewSystemScanner creates a new system scanner
func NewSystemScanner(agentVersion string) *SystemScanner {
return &SystemScanner{
agentVersion: agentVersion,
}
}
// IsAvailable always returns true since system scanning is always available
func (s *SystemScanner) IsAvailable() bool {
return true
}
// Scan collects system information and returns UpdateReportItems with full data in Metadata
func (s *SystemScanner) Scan() ([]client.UpdateReportItem, error) {
sysInfo, err := system.GetSystemInfo(s.agentVersion)
if err != nil {
return nil, fmt.Errorf("failed to get system info: %w", err)
}
var items []client.UpdateReportItem
// CPU
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-cpu",
PackageDescription: "System CPU information",
CurrentVersion: fmt.Sprintf("%d cores, %d threads", sysInfo.CPUInfo.Cores, sysInfo.CPUInfo.Threads),
AvailableVersion: sysInfo.CPUInfo.ModelName,
Severity: "low",
RepositorySource: "cpu",
Metadata: map[string]interface{}{
"metric_name": "system-cpu",
"metric_type": "cpu",
"cpu_model": sysInfo.CPUInfo.ModelName,
"cpu_cores": fmt.Sprintf("%d", sysInfo.CPUInfo.Cores),
"cpu_threads": fmt.Sprintf("%d", sysInfo.CPUInfo.Threads),
},
})
// Memory
memSeverity := "low"
switch {
case sysInfo.MemoryInfo.UsedPercent >= 95:
memSeverity = "critical"
case sysInfo.MemoryInfo.UsedPercent >= 90:
memSeverity = "important"
case sysInfo.MemoryInfo.UsedPercent >= 80:
memSeverity = "moderate"
}
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-memory",
PackageDescription: "System memory information",
CurrentVersion: fmt.Sprintf("%.1f%% used", sysInfo.MemoryInfo.UsedPercent),
AvailableVersion: fmt.Sprintf("%d GB total", sysInfo.MemoryInfo.Total/(1024*1024*1024)),
Severity: memSeverity,
RepositorySource: "memory",
Metadata: map[string]interface{}{
"metric_name": "system-memory",
"metric_type": "memory",
"memory_total": fmt.Sprintf("%d", sysInfo.MemoryInfo.Total),
"memory_used": fmt.Sprintf("%d", sysInfo.MemoryInfo.Used),
"memory_available": fmt.Sprintf("%d", sysInfo.MemoryInfo.Available),
"memory_used_percent": fmt.Sprintf("%.1f", sysInfo.MemoryInfo.UsedPercent),
},
})
// Processes
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-processes",
PackageDescription: "Running processes",
CurrentVersion: fmt.Sprintf("%d processes", sysInfo.RunningProcesses),
AvailableVersion: "n/a",
Severity: "low",
RepositorySource: "processes",
Metadata: map[string]interface{}{
"metric_name": "system-processes",
"metric_type": "processes",
"process_count": fmt.Sprintf("%d", sysInfo.RunningProcesses),
},
})
// Uptime
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-uptime",
PackageDescription: "System uptime",
CurrentVersion: sysInfo.Uptime,
AvailableVersion: "n/a",
Severity: "low",
RepositorySource: "uptime",
Metadata: map[string]interface{}{
"metric_name": "system-uptime",
"metric_type": "uptime",
"uptime": sysInfo.Uptime,
},
})
// Reboot required
if sysInfo.RebootRequired {
items = append(items, client.UpdateReportItem{
PackageType: "system",
PackageName: "system-reboot",
PackageDescription: "System reboot status",
CurrentVersion: "required",
AvailableVersion: "n/a",
Severity: "important",
RepositorySource: "reboot",
Metadata: map[string]interface{}{
"metric_name": "system-reboot",
"metric_type": "reboot",
"reboot_required": "true",
"reboot_reason": sysInfo.RebootReason,
},
})
}
return items, nil
}
// Name returns the scanner name
func (s *SystemScanner) Name() string {
return "System Metrics Reporter"
}