Session 4 complete - RedFlag update management platform

🚩 Private development - version retention only

 Complete web dashboard (React + TypeScript + TailwindCSS)
 Production-ready server backend (Go + Gin + PostgreSQL)
 Linux agent with APT + Docker scanning + local CLI tools
 JWT authentication and REST API
 Update discovery and approval workflow

🚧 Status: Alpha software - active development
📦 Purpose: Version retention during development
⚠️  Not for public use or deployment
This commit is contained in:
Fimeg
2025-10-13 16:46:31 -04:00
commit 55b7d03010
57 changed files with 7326 additions and 0 deletions

129
aggregator-agent/internal/cache/local.go vendored Normal file
View File

@@ -0,0 +1,129 @@
package cache
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/aggregator-project/aggregator-agent/internal/client"
"github.com/google/uuid"
)
// 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"`
}
// CacheDir is the directory where local cache is stored
const CacheDir = "/var/lib/aggregator"
// 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(CacheDir, CacheFile)
}
// Load reads the local cache from disk
func Load() (*LocalCache, error) {
cachePath := GetCachePath()
// 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 {
cachePath := GetCachePath()
// Ensure cache directory exists
if err := os.MkdirAll(CacheDir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
// 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) {
c.LastScanTime = time.Now()
c.Updates = updates
c.UpdateCount = len(updates)
}
// UpdateCheckIn updates the last check-in time
func (c *LocalCache) UpdateCheckIn() {
c.LastCheckIn = time.Now()
}
// SetAgentInfo sets agent identification information
func (c *LocalCache) SetAgentInfo(agentID uuid.UUID, serverURL string) {
c.AgentID = agentID
c.ServerURL = serverURL
}
// SetAgentStatus sets the current agent status
func (c *LocalCache) SetAgentStatus(status string) {
c.AgentStatus = status
}
// 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 = ""
}

View File

@@ -0,0 +1,242 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"time"
"github.com/google/uuid"
)
// Client handles API communication with the server
type Client struct {
baseURL string
token string
http *http.Client
}
// NewClient creates a new API client
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: baseURL,
token: token,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// RegisterRequest is the payload for agent registration
type RegisterRequest struct {
Hostname string `json:"hostname"`
OSType string `json:"os_type"`
OSVersion string `json:"os_version"`
OSArchitecture string `json:"os_architecture"`
AgentVersion string `json:"agent_version"`
Metadata map[string]string `json:"metadata"`
}
// RegisterResponse is returned after successful registration
type RegisterResponse struct {
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"`
Config map[string]interface{} `json:"config"`
}
// Register registers the agent with the server
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("registration failed: %s - %s", resp.Status, string(bodyBytes))
}
var result RegisterResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
// Update client token
c.token = result.Token
return &result, nil
}
// Command represents a command from the server
type Command struct {
ID string `json:"id"`
Type string `json:"type"`
Params map[string]interface{} `json:"params"`
}
// CommandsResponse contains pending commands
type CommandsResponse struct {
Commands []Command `json:"commands"`
}
// GetCommands retrieves pending commands from the server
func (c *Client) GetCommands(agentID uuid.UUID) ([]Command, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s/commands", c.baseURL, agentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get commands: %s - %s", resp.Status, string(bodyBytes))
}
var result CommandsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Commands, nil
}
// UpdateReport represents discovered updates
type UpdateReport struct {
CommandID string `json:"command_id"`
Timestamp time.Time `json:"timestamp"`
Updates []UpdateReportItem `json:"updates"`
}
// UpdateReportItem represents a single update
type UpdateReportItem struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
PackageDescription string `json:"package_description"`
CurrentVersion string `json:"current_version"`
AvailableVersion string `json:"available_version"`
Severity string `json:"severity"`
CVEList []string `json:"cve_list"`
KBID string `json:"kb_id"`
RepositorySource string `json:"repository_source"`
SizeBytes int64 `json:"size_bytes"`
Metadata map[string]interface{} `json:"metadata"`
}
// ReportUpdates sends discovered updates to the server
func (c *Client) ReportUpdates(agentID uuid.UUID, report UpdateReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/updates", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report updates: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// LogReport represents an execution log
type LogReport struct {
CommandID string `json:"command_id"`
Action string `json:"action"`
Result string `json:"result"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
ExitCode int `json:"exit_code"`
DurationSeconds int `json:"duration_seconds"`
}
// ReportLog sends an execution log to the server
func (c *Client) ReportLog(agentID uuid.UUID, report LogReport) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/logs", c.baseURL, agentID)
body, err := json.Marshal(report)
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report log: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// DetectSystem returns basic system information
func DetectSystem() (osType, osVersion, osArch string) {
osType = runtime.GOOS
osArch = runtime.GOARCH
// Read OS version (simplified for now)
switch osType {
case "linux":
data, _ := os.ReadFile("/etc/os-release")
if data != nil {
// Parse os-release file (simplified)
osVersion = "Linux"
}
case "windows":
osVersion = "Windows"
case "darwin":
osVersion = "macOS"
}
return
}

View File

@@ -0,0 +1,63 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
)
// Config holds agent configuration
type Config struct {
ServerURL string `json:"server_url"`
AgentID uuid.UUID `json:"agent_id"`
Token string `json:"token"`
CheckInInterval int `json:"check_in_interval"`
}
// Load reads configuration from file
func Load(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 empty config if file doesn't exist
return &Config{}, nil
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
return &config, nil
}
// 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)
}
if err := os.WriteFile(configPath, data, 0600); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
// IsRegistered checks if the agent is registered
func (c *Config) IsRegistered() bool {
return c.AgentID != uuid.Nil && c.Token != ""
}

View File

@@ -0,0 +1,401 @@
package display
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/aggregator-project/aggregator-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,90 @@
package scanner
import (
"bufio"
"bytes"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/aggregator-project/aggregator-agent/internal/client"
)
// APTScanner scans for APT package updates
type APTScanner struct{}
// NewAPTScanner creates a new APT scanner
func NewAPTScanner() *APTScanner {
return &APTScanner{}
}
// IsAvailable checks if APT is available on this system
func (s *APTScanner) IsAvailable() bool {
_, err := exec.LookPath("apt")
return err == nil
}
// Scan scans for available APT updates
func (s *APTScanner) Scan() ([]client.UpdateReportItem, error) {
// Update package cache (sudo may be required, but try anyway)
updateCmd := exec.Command("apt-get", "update")
updateCmd.Run() // Ignore errors since we might not have sudo
// Get upgradable packages
cmd := exec.Command("apt", "list", "--upgradable")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to run apt list: %w", err)
}
return parseAPTOutput(output)
}
func parseAPTOutput(output []byte) ([]client.UpdateReportItem, error) {
var updates []client.UpdateReportItem
scanner := bufio.NewScanner(bytes.NewReader(output))
// Regex to parse apt output:
// package/repo version arch [upgradable from: old_version]
re := regexp.MustCompile(`^([^\s/]+)/([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\[upgradable from:\s+([^\]]+)\]`)
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Listing...") {
continue
}
matches := re.FindStringSubmatch(line)
if len(matches) < 6 {
continue
}
packageName := matches[1]
repository := matches[2]
newVersion := matches[3]
oldVersion := matches[5]
// Determine severity (simplified - in production, query Ubuntu Security Advisories)
severity := "moderate"
if strings.Contains(repository, "security") {
severity = "important"
}
update := client.UpdateReportItem{
PackageType: "apt",
PackageName: packageName,
CurrentVersion: oldVersion,
AvailableVersion: newVersion,
Severity: severity,
RepositorySource: repository,
Metadata: map[string]interface{}{
"architecture": matches[4],
},
}
updates = append(updates, update)
}
return updates, nil
}

View File

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

View File

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