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>
380 lines
11 KiB
Go
380 lines
11 KiB
Go
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 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)
|
|
} |