package scanner import ( "encoding/json" "fmt" "os/exec" "runtime" "strings" "time" "github.com/aggregator-project/aggregator-agent/internal/client" ) // WingetPackage represents a single package from winget output 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"` } // WingetScanner scans for Windows package updates using winget type WingetScanner struct{} // NewWingetScanner creates a new Winget scanner func NewWingetScanner() *WingetScanner { return &WingetScanner{} } // IsAvailable checks if winget is available on this system func (s *WingetScanner) IsAvailable() bool { // Only available on Windows if runtime.GOOS != "windows" { return false } // Check if winget command exists _, err := exec.LookPath("winget") return err == nil } // Scan scans for available winget package updates func (s *WingetScanner) Scan() ([]client.UpdateReportItem, error) { if !s.IsAvailable() { return nil, fmt.Errorf("winget is not available on this system") } // Try multiple approaches with proper error handling var lastErr error // Method 1: Standard winget list with JSON output if updates, err := s.scanWithJSON(); err == nil { return updates, nil } else { lastErr = err fmt.Printf("Winget JSON scan failed: %v\n", err) } // Method 2: Fallback to basic winget list without JSON if updates, err := s.scanWithBasicOutput(); err == nil { return updates, nil } else { lastErr = fmt.Errorf("both winget scan methods failed: %v (last error)", err) fmt.Printf("Winget basic scan failed: %v\n", err) } // Method 3: Attempt automatic recovery for known issues if isKnownWingetError(lastErr) { fmt.Printf("Attempting automatic winget recovery...\n") if updates, err := s.attemptWingetRecovery(); err == nil { fmt.Printf("Winget recovery successful, found %d updates\n", len(updates)) return updates, nil } else { fmt.Printf("Winget recovery failed: %v\n", err) } return nil, fmt.Errorf("winget encountered a known issue (exit code %s). This may be due to Windows Update service or system configuration. Automatic recovery was attempted but failed", getExitCode(lastErr)) } return nil, lastErr } // scanWithJSON attempts to scan using JSON output (most reliable) func (s *WingetScanner) scanWithJSON() ([]client.UpdateReportItem, error) { // Run winget list command to get outdated packages // Using --output json for structured output cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements", "--output", "json") // Use CombinedOutput to capture both stdout and stderr for better error handling output, err := cmd.CombinedOutput() if err != nil { // Check for specific exit codes that might be transient if isTransientError(err) { return nil, fmt.Errorf("winget temporary failure: %w", err) } return nil, fmt.Errorf("failed to run winget list: %w (output: %s)", err, string(output)) } // Parse JSON output var packages []WingetPackage if err := json.Unmarshal(output, &packages); err != nil { return nil, fmt.Errorf("failed to parse winget JSON output: %w (output: %s)", err, string(output)) } var updates []client.UpdateReportItem // Convert each package to our UpdateReportItem format for _, pkg := range packages { // Skip if no available update if pkg.Available == "" || pkg.Available == pkg.Version { continue } updateItem := s.parseWingetPackage(pkg) updates = append(updates, *updateItem) } return updates, nil } // scanWithBasicOutput falls back to parsing text output func (s *WingetScanner) scanWithBasicOutput() ([]client.UpdateReportItem, error) { cmd := exec.Command("winget", "list", "--outdated", "--accept-source-agreements") output, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("failed to run winget list basic: %w", err) } // Simple text parsing fallback return s.parseWingetTextOutput(string(output)) } // parseWingetTextOutput parses winget text output as fallback func (s *WingetScanner) parseWingetTextOutput(output string) ([]client.UpdateReportItem, error) { var updates []client.UpdateReportItem lines := strings.Split(output, "\n") for _, line := range lines { line = strings.TrimSpace(line) // Skip header lines and empty lines if strings.HasPrefix(line, "Name") || strings.HasPrefix(line, "-") || line == "" { continue } // Simple parsing for tab or space-separated values fields := strings.Fields(line) if len(fields) >= 3 { pkgName := fields[0] currentVersion := fields[1] availableVersion := fields[2] // Skip if no update available if availableVersion == currentVersion || availableVersion == "Unknown" { continue } update := client.UpdateReportItem{ PackageType: "winget", PackageName: pkgName, CurrentVersion: currentVersion, AvailableVersion: availableVersion, Severity: s.determineSeverityFromName(pkgName), RepositorySource: "winget", PackageDescription: fmt.Sprintf("Update available for %s", pkgName), Metadata: map[string]interface{}{ "package_manager": "winget", "detected_via": "text_parser", }, } updates = append(updates, update) } } return updates, nil } // isTransientError checks if the error might be temporary func isTransientError(err error) bool { if err == nil { return false } errStr := err.Error() // Common transient error patterns transientPatterns := []string{ "network error", "timeout", "connection refused", "temporary failure", "service unavailable", } for _, pattern := range transientPatterns { if strings.Contains(strings.ToLower(errStr), pattern) { return true } } return false } // isKnownWingetError checks for known Winget issues func isKnownWingetError(err error) bool { if err == nil { return false } errStr := err.Error() // Check for the specific exit code 0x8a150002 if strings.Contains(errStr, "2316632066") || strings.Contains(errStr, "0x8a150002") { return true } // Other known Winget issues knownPatterns := []string{ "winget is not recognized", "windows package manager", "windows app installer", "restarting your computer", } for _, pattern := range knownPatterns { if strings.Contains(strings.ToLower(errStr), pattern) { return true } } return false } // getExitCode extracts exit code from error if available func getExitCode(err error) string { if err == nil { return "unknown" } // Try to extract exit code from error message errStr := err.Error() if strings.Contains(errStr, "exit status") { // Extract exit status number parts := strings.Fields(errStr) for i, part := range parts { if part == "status" && i+1 < len(parts) { return parts[i+1] } } } return "unknown" } // determineSeverityFromName provides basic severity detection for fallback func (s *WingetScanner) determineSeverityFromName(name string) string { lowerName := strings.ToLower(name) // Security tools get higher priority if strings.Contains(lowerName, "antivirus") || strings.Contains(lowerName, "security") || strings.Contains(lowerName, "defender") || strings.Contains(lowerName, "firewall") { return "critical" } // Browsers and communication tools get high priority if strings.Contains(lowerName, "firefox") || strings.Contains(lowerName, "chrome") || strings.Contains(lowerName, "edge") || strings.Contains(lowerName, "browser") { return "high" } return "moderate" } // parseWingetPackage converts a WingetPackage to our UpdateReportItem format func (s *WingetScanner) parseWingetPackage(pkg WingetPackage) *client.UpdateReportItem { // Determine severity based on package type and source severity := s.determineSeverity(pkg) // Categorize the package type packageCategory := s.categorizePackage(pkg.Name, pkg.Source) // Create metadata with winget-specific information metadata := map[string]interface{}{ "package_id": pkg.ID, "source": pkg.Source, "category": packageCategory, "is_pinned": pkg.IsPinned, "pin_reason": pkg.PinReason, "package_manager": "winget", } // Add additional metadata based on package source if pkg.Source == "winget" { metadata["repository_type"] = "community" } else if pkg.Source == "msstore" { metadata["repository_type"] = "microsoft_store" } else { metadata["repository_type"] = "custom" } // Create the update report item updateItem := &client.UpdateReportItem{ PackageType: "winget", PackageName: pkg.Name, CurrentVersion: pkg.Version, AvailableVersion: pkg.Available, Severity: severity, RepositorySource: pkg.Source, Metadata: metadata, } // Add description if available (would need additional winget calls) // For now, we'll use the package name as description updateItem.PackageDescription = fmt.Sprintf("Update available for %s from %s", pkg.Name, pkg.Source) return updateItem } // determineSeverity determines the severity of a package update based on various factors func (s *WingetScanner) determineSeverity(pkg WingetPackage) string { name := strings.ToLower(pkg.Name) source := strings.ToLower(pkg.Source) // Security tools get higher priority if strings.Contains(name, "antivirus") || strings.Contains(name, "security") || strings.Contains(name, "firewall") || strings.Contains(name, "malware") || strings.Contains(name, "defender") || strings.Contains(name, "crowdstrike") || strings.Contains(name, "sophos") || strings.Contains(name, "symantec") { return "critical" } // Browsers and communication tools get high priority if strings.Contains(name, "firefox") || strings.Contains(name, "chrome") || strings.Contains(name, "edge") || strings.Contains(name, "browser") || strings.Contains(name, "zoom") || strings.Contains(name, "teams") || strings.Contains(name, "slack") || strings.Contains(name, "discord") { return "high" } // Development tools if strings.Contains(name, "visual studio") || strings.Contains(name, "vscode") || strings.Contains(name, "git") || strings.Contains(name, "docker") || strings.Contains(name, "nodejs") || strings.Contains(name, "python") || strings.Contains(name, "java") || strings.Contains(name, "powershell") { return "moderate" } // Microsoft Store apps might be less critical if source == "msstore" { return "low" } // Default severity return "moderate" } // categorizePackage categorizes the package based on name and source func (s *WingetScanner) categorizePackage(name, source string) string { lowerName := strings.ToLower(name) // Development tools if strings.Contains(lowerName, "visual studio") || strings.Contains(lowerName, "vscode") || strings.Contains(lowerName, "intellij") || strings.Contains(lowerName, "sublime") || strings.Contains(lowerName, "notepad++") || strings.Contains(lowerName, "git") || strings.Contains(lowerName, "docker") || strings.Contains(lowerName, "nodejs") || strings.Contains(lowerName, "python") || strings.Contains(lowerName, "java") || strings.Contains(lowerName, "rust") || strings.Contains(lowerName, "go") || strings.Contains(lowerName, "github") || strings.Contains(lowerName, "postman") || strings.Contains(lowerName, "wireshark") { return "development" } // Security tools if strings.Contains(lowerName, "antivirus") || strings.Contains(lowerName, "security") || strings.Contains(lowerName, "firewall") || strings.Contains(lowerName, "malware") || strings.Contains(lowerName, "defender") || strings.Contains(lowerName, "crowdstrike") || strings.Contains(lowerName, "sophos") || strings.Contains(lowerName, "symantec") || strings.Contains(lowerName, "vpn") || strings.Contains(lowerName, "1password") || strings.Contains(lowerName, "bitwarden") || strings.Contains(lowerName, "lastpass") { return "security" } // Browsers if strings.Contains(lowerName, "firefox") || strings.Contains(lowerName, "chrome") || strings.Contains(lowerName, "edge") || strings.Contains(lowerName, "opera") || strings.Contains(lowerName, "brave") || strings.Contains(lowerName, "vivaldi") || strings.Contains(lowerName, "browser") { return "browser" } // Communication tools if strings.Contains(lowerName, "zoom") || strings.Contains(lowerName, "teams") || strings.Contains(lowerName, "slack") || strings.Contains(lowerName, "discord") || strings.Contains(lowerName, "telegram") || strings.Contains(lowerName, "whatsapp") || strings.Contains(lowerName, "skype") || strings.Contains(lowerName, "outlook") { return "communication" } // Media and entertainment if strings.Contains(lowerName, "vlc") || strings.Contains(lowerName, "spotify") || strings.Contains(lowerName, "itunes") || strings.Contains(lowerName, "plex") || strings.Contains(lowerName, "kodi") || strings.Contains(lowerName, "obs") || strings.Contains(lowerName, "streamlabs") { return "media" } // Productivity tools if strings.Contains(lowerName, "microsoft office") || strings.Contains(lowerName, "word") || strings.Contains(lowerName, "excel") || strings.Contains(lowerName, "powerpoint") || strings.Contains(lowerName, "adobe") || strings.Contains(lowerName, "photoshop") || strings.Contains(lowerName, "acrobat") || strings.Contains(lowerName, "notion") || strings.Contains(lowerName, "obsidian") || strings.Contains(lowerName, "typora") { return "productivity" } // System utilities if strings.Contains(lowerName, "7-zip") || strings.Contains(lowerName, "winrar") || strings.Contains(lowerName, "ccleaner") || strings.Contains(lowerName, "process") || strings.Contains(lowerName, "task manager") || strings.Contains(lowerName, "cpu-z") || strings.Contains(lowerName, "gpu-z") || strings.Contains(lowerName, "hwmonitor") { return "utility" } // Gaming if strings.Contains(lowerName, "steam") || strings.Contains(lowerName, "epic") || strings.Contains(lowerName, "origin") || strings.Contains(lowerName, "uplay") || strings.Contains(lowerName, "gog") || strings.Contains(lowerName, "discord") { // Discord is also gaming return "gaming" } // Default category return "application" } // GetPackageDetails retrieves detailed information about a specific winget package func (s *WingetScanner) GetPackageDetails(packageID string) (*client.UpdateReportItem, error) { if !s.IsAvailable() { return nil, fmt.Errorf("winget is not available on this system") } // Run winget show command to get detailed package information cmd := exec.Command("winget", "show", "--id", packageID, "--output", "json") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run winget show: %w", err) } // Parse JSON output (winget show outputs a single package object) var pkg WingetPackage if err := json.Unmarshal(output, &pkg); err != nil { return nil, fmt.Errorf("failed to parse winget show output: %w", err) } // Convert to UpdateReportItem format updateItem := s.parseWingetPackage(pkg) return updateItem, nil } // GetInstalledPackages retrieves all installed packages via winget func (s *WingetScanner) GetInstalledPackages() ([]WingetPackage, error) { if !s.IsAvailable() { return nil, fmt.Errorf("winget is not available on this system") } // Run winget list command to get all installed packages cmd := exec.Command("winget", "list", "--output", "json") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to run winget list: %w", err) } // Parse JSON output var packages []WingetPackage if err := json.Unmarshal(output, &packages); err != nil { return nil, fmt.Errorf("failed to parse winget JSON output: %w", err) } return packages, nil } // attemptWingetRecovery tries to fix common winget issues automatically func (s *WingetScanner) attemptWingetRecovery() ([]client.UpdateReportItem, error) { fmt.Printf("Starting winget recovery process...\n") // Recovery Method 1: Reset winget sources (common fix) fmt.Printf("Attempting to reset winget sources...\n") if err := s.resetWingetSources(); err == nil { if updates, scanErr := s.scanWithJSON(); scanErr == nil { fmt.Printf("Recovery successful after source reset\n") return updates, nil } } // Recovery Method 2: Update winget itself (silent) fmt.Printf("Attempting to update winget itself...\n") if err := s.updateWingetSilent(); err == nil { // Wait a moment for winget to stabilize time.Sleep(2 * time.Second) if updates, scanErr := s.scanWithJSON(); scanErr == nil { fmt.Printf("Recovery successful after winget update\n") return updates, nil } } // Recovery Method 3: Repair Windows App Installer (winget backend) fmt.Printf("Attempting to repair Windows App Installer...\n") if err := s.repairWindowsAppInstaller(); err == nil { // Wait longer for system repairs time.Sleep(5 * time.Second) if updates, scanErr := s.scanWithJSON(); scanErr == nil { fmt.Printf("Recovery successful after Windows App Installer repair\n") return updates, nil } } // Recovery Method 4: Force refresh with admin privileges fmt.Printf("Attempting admin refresh...\n") if updates, err := s.scanWithAdminPrivileges(); err == nil { fmt.Printf("Recovery successful with admin privileges\n") return updates, nil } // If all recovery attempts failed, return the original error return nil, fmt.Errorf("all winget recovery attempts failed") } // resetWingetSources resets winget package sources func (s *WingetScanner) resetWingetSources() error { // Reset winget sources silently cmd := exec.Command("winget", "source", "reset", "--force") _, err := cmd.CombinedOutput() if err != nil { fmt.Printf("Failed to reset winget sources: %v\n", err) return err } // Add default sources back cmd = exec.Command("winget", "source", "add", "winget", "--accept-package-agreements", "--accept-source-agreements") _, err = cmd.CombinedOutput() if err != nil { fmt.Printf("Failed to add winget source: %v\n", err) return err } return nil } // updateWingetSilent updates winget itself silently func (s *WingetScanner) updateWingetSilent() error { // Update winget silently with no interaction cmd := exec.Command("winget", "upgrade", "--id", "Microsoft.AppInstaller", "--silent", "--accept-package-agreements", "--accept-source-agreements") _, err := cmd.CombinedOutput() if err != nil { fmt.Printf("Failed to update winget: %v\n", err) return err } return nil } // repairWindowsAppInstaller attempts to repair the Windows App Installer func (s *WingetScanner) repairWindowsAppInstaller() error { // Try to repair using PowerShell psCmd := `Get-AppxPackage -Name "Microsoft.DesktopAppInstaller" | Repair-AppxPackage -ForceUpdateFromAnyVersion` cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-Command", psCmd) _, err := cmd.CombinedOutput() if err != nil { fmt.Printf("Failed to repair Windows App Installer: %v\n", err) return err } return nil } // scanWithAdminPrivileges attempts to scan with elevated privileges if available func (s *WingetScanner) scanWithAdminPrivileges() ([]client.UpdateReportItem, error) { // Try running with elevated privileges using PowerShell psCmd := `Start-Process winget -ArgumentList "list","--outdated","--accept-source-agreements" -Verb RunAs -Wait` cmd := exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-Command", psCmd) // This will likely fail without actual admin privileges, but we try anyway _, err := cmd.CombinedOutput() if err != nil { // Fallback to regular scan with different flags return s.scanWithDifferentFlags() } // If admin scan succeeded, try to get the results return s.scanWithBasicOutput() } // scanWithDifferentFlags tries alternative winget flags func (s *WingetScanner) scanWithDifferentFlags() ([]client.UpdateReportItem, error) { // Try different combination of flags flagVariations := [][]string{ {"list", "--outdated", "--accept-source-agreements"}, {"list", "--outdated", "--include-unknown"}, {"list", "--outdated"}, } for _, flags := range flagVariations { cmd := exec.Command("winget", flags...) output, err := cmd.CombinedOutput() if err == nil { // Try to parse the output if updates, parseErr := s.parseWingetTextOutput(string(output)); parseErr == nil { return updates, nil } } } return nil, fmt.Errorf("all flag variations failed") }