Files
Redflag/aggregator-agent/internal/scanner/dnf.go
jpetree331 f97d4845af feat(security): A-1 Ed25519 key rotation + A-2 replay attack fixes
Complete RedFlag codebase with two major security audit implementations.

== A-1: Ed25519 Key Rotation Support ==

Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management

Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing

== A-2: Replay Attack Fixes (F-1 through F-7) ==

F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH     - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH     - Migration 026: expires_at column with partial index
F-6 HIGH     - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH     - Agent-side executedIDs dedup map with cleanup
F-4 HIGH     - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt

Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.

All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:25:47 -04:00

157 lines
4.5 KiB
Go

package scanner
import (
"bufio"
"bytes"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/Fimeg/RedFlag/aggregator-agent/internal/client"
)
// DNFScanner scans for DNF/RPM package updates
type DNFScanner struct{}
// NewDNFScanner creates a new DNF scanner
func NewDNFScanner() *DNFScanner {
return &DNFScanner{}
}
// IsAvailable checks if DNF is available on this system
func (s *DNFScanner) IsAvailable() bool {
_, err := exec.LookPath("dnf")
return err == nil
}
// Scan scans for available DNF updates
func (s *DNFScanner) Scan() ([]client.UpdateReportItem, error) {
// Check for updates (don't update cache to avoid needing sudo)
cmd := exec.Command("dnf", "check-update")
output, err := cmd.Output()
if err != nil {
// dnf check-update returns exit code 100 when updates are available
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
// Updates are available, continue processing
} else {
return nil, fmt.Errorf("failed to run dnf check-update: %w", err)
}
}
return parseDNFOutput(output)
}
func parseDNFOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
// Regex to parse dnf check-update output:
// package-name.version arch new-version
re := regexp.MustCompile(`^([^\s]+)\.([^\s]+)\s+([^\s]+)\s+([^\s]+)$`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and header/footer
if line == "" ||
strings.HasPrefix(line, "Last metadata") ||
strings.HasPrefix(line, "Dependencies") ||
strings.HasPrefix(line, "Obsoleting") ||
strings.Contains(line, "Upgraded") {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) < 5 {
continue
}
packageName := matches[1]
arch := matches[2]
repoAndVersion := matches[3]
newVersion := matches[4]
// Extract repository and current version from repoAndVersion
// Format is typically: repo-version current-version
parts := strings.Fields(repoAndVersion)
var repository, currentVersion string
if len(parts) >= 2 {
repository = parts[0]
currentVersion = parts[1]
} else if len(parts) == 1 {
repository = parts[0]
// Try to get current version from rpm
currentVersion = getInstalledVersion(packageName)
}
// Determine severity based on repository and update type
severity := determineSeverity(repository, packageName, newVersion)
update := client.UpdateReportItem{
PackageType: "dnf",
PackageName: packageName,
CurrentVersion: currentVersion,
AvailableVersion: newVersion,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{
"architecture": arch,
},
}
updates = append(updates, update)
}
return updates, nil
}
// getInstalledVersion gets the currently installed version of a package
func getInstalledVersion(packageName string) string {
cmd := exec.Command("rpm", "-q", "--queryformat", "%{VERSION}", packageName)
output, err := cmd.Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(output))
}
// determineSeverity determines the severity of an update based on repository and package information
func determineSeverity(repository, packageName, newVersion string) string {
// Security updates
if strings.Contains(strings.ToLower(repository), "security") ||
strings.Contains(strings.ToLower(repository), "updates") ||
strings.Contains(strings.ToLower(packageName), "security") ||
strings.Contains(strings.ToLower(packageName), "selinux") ||
strings.Contains(strings.ToLower(packageName), "crypto") ||
strings.Contains(strings.ToLower(packageName), "openssl") ||
strings.Contains(strings.ToLower(packageName), "gnutls") {
return "critical"
}
// Kernel updates are important
if strings.Contains(strings.ToLower(packageName), "kernel") {
return "important"
}
// Core system packages
if strings.Contains(strings.ToLower(packageName), "glibc") ||
strings.Contains(strings.ToLower(packageName), "systemd") ||
strings.Contains(strings.ToLower(packageName), "bash") ||
strings.Contains(strings.ToLower(packageName), "coreutils") {
return "important"
}
// Development tools
if strings.Contains(strings.ToLower(packageName), "gcc") ||
strings.Contains(strings.ToLower(packageName), "python") ||
strings.Contains(strings.ToLower(packageName), "nodejs") ||
strings.Contains(strings.ToLower(packageName), "java") ||
strings.Contains(strings.ToLower(packageName), "go") {
return "moderate"
}
// Default severity
return "low"
}