From ea170118a774e2f194d0b07c120e3e40fb3f7071 Mon Sep 17 00:00:00 2001 From: gus Date: Tue, 24 Mar 2026 17:49:14 -0300 Subject: [PATCH] feat: add aguara check and aguara clean commands Incident response commands for compromised Python packages. aguara check: - Discovers Python site-packages (virtualenv, system, or --path) - Detects known compromised package versions (litellm 1.82.7/1.82.8) - Scans .pth files for executable content - Checks for persistence artifacts (systemd, sysmon backdoor) - Reports credential files at risk with rotation guidance - JSON and terminal output aguara clean: - Uninstalls compromised packages (pip/uv) - Quarantines malicious .pth files to /tmp/aguara-quarantine/ - Disables systemd persistence services - Supports --dry-run and --purge-caches - Requires confirmation (--yes to skip) - Prints credential rotation checklist 574 tests, 0 lint issues. --- cmd/aguara/commands/check.go | 156 +++++++++++++ cmd/aguara/commands/clean.go | 170 ++++++++++++++ internal/incident/checker.go | 357 ++++++++++++++++++++++++++++++ internal/incident/checker_test.go | 157 +++++++++++++ internal/incident/cleaner.go | 206 +++++++++++++++++ internal/incident/cleaner_test.go | 102 +++++++++ internal/incident/compromised.go | 39 ++++ 7 files changed, 1187 insertions(+) create mode 100644 cmd/aguara/commands/check.go create mode 100644 cmd/aguara/commands/clean.go create mode 100644 internal/incident/checker.go create mode 100644 internal/incident/checker_test.go create mode 100644 internal/incident/cleaner.go create mode 100644 internal/incident/cleaner_test.go create mode 100644 internal/incident/compromised.go diff --git a/cmd/aguara/commands/check.go b/cmd/aguara/commands/check.go new file mode 100644 index 0000000..9de9877 --- /dev/null +++ b/cmd/aguara/commands/check.go @@ -0,0 +1,156 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/garagon/aguara/internal/incident" + "github.com/spf13/cobra" +) + +var ( + flagCheckPath string + flagCheckIncludeCaches bool +) + +var checkCmd = &cobra.Command{ + Use: "check", + Short: "Check for compromised Python packages and persistence artifacts", + Long: `Scan installed Python packages for known compromised versions, malicious .pth +files, and persistence backdoors. Reports which credential files are at risk.`, + RunE: runCheck, +} + +func init() { + checkCmd.Flags().StringVar(&flagCheckPath, "path", "", "Path to site-packages directory (default: auto-discover)") + checkCmd.Flags().BoolVar(&flagCheckIncludeCaches, "include-caches", false, "Also check pip/uv cache directories") + rootCmd.AddCommand(checkCmd) +} + +func runCheck(cmd *cobra.Command, args []string) error { + result, err := incident.Check(incident.CheckOptions{ + Path: flagCheckPath, + IncludeCaches: flagCheckIncludeCaches, + }) + if err != nil { + return err + } + + if flagFormat == "json" { + return writeCheckJSON(result) + } + return writeCheckTerminal(result) +} + +func writeCheckJSON(result *incident.CheckResult) error { + w := os.Stdout + if flagOutput != "" { + f, err := os.Create(flagOutput) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + w = f + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func writeCheckTerminal(result *incident.CheckResult) error { + fmt.Printf("\nScanning Python environment: %s\n", result.Environment) + fmt.Printf("Packages read: %d | .pth files scanned: %d\n\n", result.PackagesRead, result.PthScanned) + + if len(result.Findings) == 0 { + green := "\033[32m" + reset := "\033[0m" + if flagNoColor { + green = "" + reset = "" + } + fmt.Printf(" %s\u2714 No compromised packages or artifacts found.%s\n\n", green, reset) + return nil + } + + red := "\033[31m" + yellow := "\033[33m" + bold := "\033[1m" + dim := "\033[2m" + reset := "\033[0m" + cyan := "\033[36m" + if flagNoColor { + red = "" + yellow = "" + bold = "" + dim = "" + reset = "" + cyan = "" + } + + for _, f := range result.Findings { + var sevColor string + switch f.Severity { + case incident.SevCritical: + sevColor = red + bold + case incident.SevWarning: + sevColor = yellow + default: + sevColor = cyan + } + fmt.Printf("%s%-10s%s %s\n", sevColor, f.Severity, reset, f.Title) + if f.Path != "" { + fmt.Printf(" %sPath: %s%s\n", dim, f.Path, reset) + } + if f.Detail != "" { + fmt.Printf(" %s%s%s\n", dim, f.Detail, reset) + } + fmt.Println() + } + + // Credentials at risk + atRisk := 0 + for _, c := range result.Credentials { + if c.Exists { + atRisk++ + } + } + if atRisk > 0 { + fmt.Printf("%sCredentials at risk:%s\n", bold, reset) + for _, c := range result.Credentials { + if c.Exists { + fmt.Printf(" %-30s %sEXISTS%s %s%s%s\n", c.Path, red, reset, dim, c.Guidance, reset) + } + } + fmt.Println() + } + + // Action guidance + fmt.Printf("%sAction required:%s\n", bold, reset) + fmt.Println(" 1. Run 'aguara clean' to remove malicious files") + fmt.Println(" 2. Rotate ALL credentials listed above") + fmt.Println(" 3. If running K8s: kubectl get pods -n kube-system | grep node-setup") + + // Build summary line + critCount := 0 + warnCount := 0 + for _, f := range result.Findings { + switch f.Severity { + case incident.SevCritical: + critCount++ + case incident.SevWarning: + warnCount++ + } + } + var parts []string + if critCount > 0 { + parts = append(parts, fmt.Sprintf("%s%d critical%s", red, critCount, reset)) + } + if warnCount > 0 { + parts = append(parts, fmt.Sprintf("%s%d warning%s", yellow, warnCount, reset)) + } + fmt.Printf("\n%s\n", strings.Join(parts, " \u00b7 ")) + + return nil +} diff --git a/cmd/aguara/commands/clean.go b/cmd/aguara/commands/clean.go new file mode 100644 index 0000000..5c4d3fe --- /dev/null +++ b/cmd/aguara/commands/clean.go @@ -0,0 +1,170 @@ +package commands + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/garagon/aguara/internal/incident" + "github.com/spf13/cobra" +) + +var ( + flagCleanDryRun bool + flagCleanPurgeCaches bool + flagCleanYes bool +) + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Remove compromised packages, malicious files, and persistence artifacts", + Long: `Detects and removes compromised Python packages, quarantines malicious .pth files, +and disables persistence backdoors. Use --dry-run to preview without changes.`, + RunE: runClean, +} + +func init() { + cleanCmd.Flags().BoolVar(&flagCleanDryRun, "dry-run", false, "Show what would be removed without making changes") + cleanCmd.Flags().BoolVar(&flagCleanPurgeCaches, "purge-caches", false, "Also purge pip/uv package caches") + cleanCmd.Flags().BoolVar(&flagCleanYes, "yes", false, "Skip confirmation prompt") + rootCmd.AddCommand(cleanCmd) +} + +func runClean(cmd *cobra.Command, args []string) error { + // First run check to see what we're dealing with + checkResult, err := incident.Check(incident.CheckOptions{ + Path: flagCheckPath, + IncludeCaches: flagCleanPurgeCaches, + }) + if err != nil { + return err + } + + if len(checkResult.Findings) == 0 { + fmt.Println("\n \033[32m\u2714 No compromised packages or artifacts found.\033[0m") + return nil + } + + // Show what was found + fmt.Printf("\nFound %d issues to clean:\n\n", len(checkResult.Findings)) + for i, f := range checkResult.Findings { + fmt.Printf(" [%d] %s - %s\n", i+1, f.Severity, f.Title) + if f.Path != "" { + fmt.Printf(" %s\n", f.Path) + } + } + fmt.Println() + + if flagCleanDryRun { + fmt.Println("No changes made (dry run).") + return nil + } + + // Confirm unless --yes + if !flagCleanYes { + fmt.Print("Proceed with cleanup? [y/N] ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + // Run cleanup + result, err := incident.Clean(incident.CleanOptions{ + DryRun: false, + PurgeCaches: flagCleanPurgeCaches, + CheckOpts: incident.CheckOptions{ + Path: flagCheckPath, + IncludeCaches: flagCleanPurgeCaches, + }, + }) + if err != nil { + return err + } + + if flagFormat == "json" { + return writeCleanJSON(result) + } + return writeCleanTerminal(result) +} + +func writeCleanJSON(result *incident.CleanResult) error { + w := os.Stdout + if flagOutput != "" { + f, err := os.Create(flagOutput) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + w = f + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func writeCleanTerminal(result *incident.CleanResult) error { + red := "\033[31m" + green := "\033[32m" + bold := "\033[1m" + dim := "\033[2m" + reset := "\033[0m" + if flagNoColor { + red = "" + green = "" + bold = "" + dim = "" + reset = "" + } + + done := 0 + for i, a := range result.Actions { + status := green + "\u2714" + reset + if a.Error != "" { + status = red + "\u2716" + reset + } + if !a.Done && a.Error == "" { + status = dim + "-" + reset + } + fmt.Printf("\n[%d/%d] %s %s %s\n", i+1, len(result.Actions), status, a.Type, a.Target) + if a.Error != "" { + fmt.Printf(" %s%s%s\n", red, a.Error, reset) + } + if a.Done { + done++ + } + } + + fmt.Printf("\n%sCleaned %d/%d issues.%s", bold, done, len(result.Actions), reset) + if result.QuarantineDir != "" { + fmt.Printf(" Quarantine: %s%s%s", dim, result.QuarantineDir, reset) + } + fmt.Println() + + // Credential rotation checklist + atRisk := 0 + for _, c := range result.Credentials { + if c.Exists { + atRisk++ + } + } + if atRisk > 0 { + fmt.Printf("\n%sIMPORTANT: Rotate these credentials NOW:%s\n", bold+red, reset) + for _, c := range result.Credentials { + if c.Exists { + fmt.Printf(" %s%-30s%s %s\n", bold, c.Path, reset, c.Guidance) + } + } + fmt.Println() + fmt.Println("If running Kubernetes, also run:") + fmt.Println(" kubectl get pods -n kube-system | grep node-setup") + fmt.Println(" kubectl delete pod -n kube-system -l app=node-setup") + } + + return nil +} diff --git a/internal/incident/checker.go b/internal/incident/checker.go new file mode 100644 index 0000000..a2468e5 --- /dev/null +++ b/internal/incident/checker.go @@ -0,0 +1,357 @@ +package incident + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// Severity levels for check findings. +const ( + SevCritical = "CRITICAL" + SevWarning = "WARNING" + SevInfo = "INFO" +) + +// Finding represents a single issue found by the checker. +type Finding struct { + Severity string `json:"severity"` + Title string `json:"title"` + Detail string `json:"detail"` + Path string `json:"path,omitempty"` + Remediation string `json:"remediation,omitempty"` +} + +// CredentialFile represents a credential path and its rotation guidance. +type CredentialFile struct { + Path string `json:"path"` + Exists bool `json:"exists"` + Guidance string `json:"guidance"` +} + +// CheckResult holds all results from a check run. +type CheckResult struct { + Environment string `json:"environment"` + Findings []Finding `json:"findings"` + Credentials []CredentialFile `json:"credentials"` + PackagesRead int `json:"packages_read"` + PthScanned int `json:"pth_scanned"` +} + +// InstalledPackage is a package parsed from dist-info METADATA. +type InstalledPackage struct { + Name string + Version string + Dir string // dist-info directory path +} + +// CheckOptions configures a check run. +type CheckOptions struct { + Path string // explicit site-packages path, empty = auto-discover + IncludeCaches bool +} + +// Check scans a Python environment for compromised packages and artifacts. +func Check(opts CheckOptions) (*CheckResult, error) { + siteDir := opts.Path + if siteDir == "" { + siteDir = discoverSitePackages() + } + if siteDir == "" { + return nil, fmt.Errorf("no Python site-packages directory found (use --path to specify)") + } + + result := &CheckResult{Environment: siteDir} + + // 1. Read installed packages and check against known-bad list + packages := readInstalledPackages(siteDir) + result.PackagesRead = len(packages) + for _, pkg := range packages { + if cp := IsCompromised(pkg.Name, pkg.Version); cp != nil { + result.Findings = append(result.Findings, Finding{ + Severity: SevCritical, + Title: fmt.Sprintf("%s %s is a known compromised package (%s)", pkg.Name, pkg.Version, cp.Advisory), + Detail: cp.Summary, + Path: pkg.Dir, + Remediation: fmt.Sprintf("Run 'aguara clean' to remove %s and associated malware", pkg.Name), + }) + } + } + + // 2. Scan .pth files for executable content + pthFiles := findPthFiles(siteDir) + result.PthScanned = len(pthFiles) + for _, pth := range pthFiles { + if findings := checkPthFile(pth); len(findings) > 0 { + result.Findings = append(result.Findings, findings...) + } + } + + // 3. Check for persistence artifacts + result.Findings = append(result.Findings, checkPersistence()...) + + // 4. Check credential files at risk + result.Credentials = checkCredentialFiles() + + // 5. If --include-caches, check pip/uv caches for compromised packages + if opts.IncludeCaches { + result.Findings = append(result.Findings, checkCaches()...) + } + + return result, nil +} + +// discoverSitePackages finds the current Python environment's site-packages. +func discoverSitePackages() string { + // Check virtualenv first + if venv := os.Getenv("VIRTUAL_ENV"); venv != "" { + pattern := filepath.Join(venv, "lib", "python*", "site-packages") + matches, _ := filepath.Glob(pattern) + if len(matches) > 0 { + return matches[0] + } + } + + // Common system site-packages locations + candidates := []string{} + home, _ := os.UserHomeDir() + + if runtime.GOOS == "darwin" { + candidates = append(candidates, + "/opt/homebrew/lib/python*/site-packages", + "/usr/local/lib/python*/site-packages", + ) + } else { + candidates = append(candidates, + "/usr/lib/python*/dist-packages", + "/usr/local/lib/python*/site-packages", + ) + } + if home != "" { + candidates = append(candidates, + filepath.Join(home, ".local/lib/python*/site-packages"), + ) + } + + for _, pattern := range candidates { + matches, _ := filepath.Glob(pattern) + if len(matches) > 0 { + return matches[len(matches)-1] // highest Python version + } + } + return "" +} + +// readInstalledPackages reads METADATA from *.dist-info dirs. +func readInstalledPackages(siteDir string) []InstalledPackage { + var packages []InstalledPackage + + entries, err := os.ReadDir(siteDir) + if err != nil { + return nil + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasSuffix(name, ".dist-info") { + continue + } + + metaPath := filepath.Join(siteDir, name, "METADATA") + pkg := parseMetadata(metaPath) + if pkg.Name != "" { + pkg.Dir = filepath.Join(siteDir, name) + packages = append(packages, pkg) + } + } + return packages +} + +// parseMetadata extracts Name and Version from a dist-info METADATA file. +func parseMetadata(path string) InstalledPackage { + f, err := os.Open(path) + if err != nil { + return InstalledPackage{} + } + defer func() { _ = f.Close() }() + + var pkg InstalledPackage + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if line == "" { + break // headers end at first blank line + } + if strings.HasPrefix(line, "Name: ") { + pkg.Name = strings.ToLower(strings.TrimPrefix(line, "Name: ")) + } else if strings.HasPrefix(line, "Version: ") { + pkg.Version = strings.TrimPrefix(line, "Version: ") + } + } + return pkg +} + +// findPthFiles returns all .pth files in the given directory. +func findPthFiles(dir string) []string { + var files []string + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".pth") { + files = append(files, filepath.Join(dir, entry.Name())) + } + } + return files +} + +var pthExecRe = regexp.MustCompile(`(?i)(^import\s|subprocess|os\.system|os\.popen|exec\(|eval\(|compile\(|__import__|importlib|open\(|Path\()`) + +// checkPthFile scans a .pth file for executable content. +func checkPthFile(path string) []Finding { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + content := string(data) + if !pthExecRe.MatchString(content) { + return nil + } + + // Truncate content for display + preview := content + if len(preview) > 200 { + preview = preview[:200] + "..." + } + preview = strings.ReplaceAll(preview, "\n", " ") + + return []Finding{{ + Severity: SevCritical, + Title: fmt.Sprintf("%s contains executable code", filepath.Base(path)), + Detail: preview, + Path: path, + Remediation: "Remove this .pth file. Legitimate .pth files contain only directory paths.", + }} +} + +// checkPersistence looks for known backdoor persistence artifacts. +func checkPersistence() []Finding { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + + var findings []Finding + artifacts := []struct { + rel string + desc string + }{ + {".config/sysmon/sysmon.py", "litellm backdoor script"}, + {".config/systemd/user/sysmon.service", "litellm systemd persistence"}, + } + + for _, a := range artifacts { + path := filepath.Join(home, a.rel) + if _, err := os.Stat(path); err == nil { + findings = append(findings, Finding{ + Severity: SevWarning, + Title: fmt.Sprintf("Persistence artifact found: %s", a.desc), + Path: path, + Remediation: "Run 'aguara clean' to quarantine this file", + }) + } + } + return findings +} + +// checkCredentialFiles reports which credential files exist on the system. +func checkCredentialFiles() []CredentialFile { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + + targets := []struct { + rel string + guidance string + }{ + {".ssh/id_rsa", "Rotate SSH keys (ssh-keygen -t ed25519)"}, + {".ssh/id_ed25519", "Rotate SSH keys and update authorized_keys on all servers"}, + {".aws/credentials", "Rotate AWS access keys in IAM console"}, + {".azure/config", "Rotate Azure credentials via az cli"}, + {".gcloud/credentials.db", "Rotate GCP credentials via gcloud auth revoke"}, + {".kube/config", "Rotate K8s certificates and service account tokens"}, + {".gitconfig", "Revoke git tokens at github.com/settings/tokens"}, + {".git-credentials", "Revoke all stored git credentials"}, + {".npmrc", "Rotate npm tokens at npmjs.com/settings/tokens"}, + {".pypirc", "Rotate PyPI tokens at pypi.org/manage/account"}, + {".pgpass", "Rotate PostgreSQL passwords"}, + {".my.cnf", "Rotate MySQL passwords"}, + {".env", "Rotate all API keys and secrets in .env"}, + } + + var creds []CredentialFile + for _, t := range targets { + path := filepath.Join(home, t.rel) + _, err := os.Stat(path) + creds = append(creds, CredentialFile{ + Path: "~/" + t.rel, + Exists: err == nil, + Guidance: t.guidance, + }) + } + return creds +} + +// checkCaches looks for compromised packages in pip/uv cache dirs. +func checkCaches() []Finding { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + + var findings []Finding + cacheDirs := []string{ + filepath.Join(home, ".cache/pip/wheels"), + filepath.Join(home, ".cache/uv"), + filepath.Join(home, "Library/Caches/pip"), // macOS + } + + for _, dir := range cacheDirs { + if _, err := os.Stat(dir); err != nil { + continue + } + // Walk cache looking for compromised package names in filenames + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + base := strings.ToLower(d.Name()) + for _, cp := range KnownCompromised { + if strings.Contains(base, cp.Name) { + for _, v := range cp.Versions { + if strings.Contains(base, v) { + findings = append(findings, Finding{ + Severity: SevWarning, + Title: fmt.Sprintf("Cached compromised package: %s %s", cp.Name, v), + Path: path, + Remediation: "Run 'aguara clean --purge-caches' to remove cached packages", + }) + } + } + } + } + return nil + }) + } + return findings +} diff --git a/internal/incident/checker_test.go b/internal/incident/checker_test.go new file mode 100644 index 0000000..63db977 --- /dev/null +++ b/internal/incident/checker_test.go @@ -0,0 +1,157 @@ +package incident + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsCompromised(t *testing.T) { + assert.NotNil(t, IsCompromised("litellm", "1.82.7")) + assert.NotNil(t, IsCompromised("litellm", "1.82.8")) + assert.Nil(t, IsCompromised("litellm", "1.82.6")) + assert.Nil(t, IsCompromised("requests", "2.31.0")) +} + +func TestReadInstalledPackages(t *testing.T) { + dir := t.TempDir() + + // Create a fake dist-info with METADATA + distInfo := filepath.Join(dir, "litellm-1.82.8.dist-info") + require.NoError(t, os.Mkdir(distInfo, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(distInfo, "METADATA"), []byte( + "Metadata-Version: 2.1\nName: litellm\nVersion: 1.82.8\nSummary: LLM proxy\n", + ), 0644)) + + // Create a safe package + safeInfo := filepath.Join(dir, "requests-2.31.0.dist-info") + require.NoError(t, os.Mkdir(safeInfo, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(safeInfo, "METADATA"), []byte( + "Metadata-Version: 2.1\nName: requests\nVersion: 2.31.0\n", + ), 0644)) + + pkgs := readInstalledPackages(dir) + require.Len(t, pkgs, 2) + + names := map[string]string{} + for _, p := range pkgs { + names[p.Name] = p.Version + } + assert.Equal(t, "1.82.8", names["litellm"]) + assert.Equal(t, "2.31.0", names["requests"]) +} + +func TestCheckDetectsCompromisedPackage(t *testing.T) { + dir := t.TempDir() + + distInfo := filepath.Join(dir, "litellm-1.82.8.dist-info") + require.NoError(t, os.Mkdir(distInfo, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(distInfo, "METADATA"), []byte( + "Metadata-Version: 2.1\nName: litellm\nVersion: 1.82.8\n", + ), 0644)) + + result, err := Check(CheckOptions{Path: dir}) + require.NoError(t, err) + require.GreaterOrEqual(t, len(result.Findings), 1) + assert.Equal(t, SevCritical, result.Findings[0].Severity) + assert.Contains(t, result.Findings[0].Title, "litellm") +} + +func TestCheckDetectsMaliciousPth(t *testing.T) { + dir := t.TempDir() + + pth := filepath.Join(dir, "evil.pth") + require.NoError(t, os.WriteFile(pth, []byte( + "import subprocess; subprocess.Popen(['python', '/tmp/payload.py'])\n", + ), 0644)) + + result, err := Check(CheckOptions{Path: dir}) + require.NoError(t, err) + + hasPth := false + for _, f := range result.Findings { + if f.Severity == SevCritical && f.Path == pth { + hasPth = true + } + } + assert.True(t, hasPth, "should detect malicious .pth file") +} + +func TestCheckCleanPth(t *testing.T) { + dir := t.TempDir() + + // Legitimate .pth with only paths + pth := filepath.Join(dir, "safe.pth") + require.NoError(t, os.WriteFile(pth, []byte( + "/usr/local/lib/python3.12/site-packages\n./vendor\n", + ), 0644)) + + result, err := Check(CheckOptions{Path: dir}) + require.NoError(t, err) + + for _, f := range result.Findings { + assert.NotContains(t, f.Path, "safe.pth", "legitimate .pth should not be flagged") + } +} + +func TestCheckEmptyEnvironment(t *testing.T) { + dir := t.TempDir() + + result, err := Check(CheckOptions{Path: dir}) + require.NoError(t, err) + assert.Equal(t, 0, result.PackagesRead) + // Only persistence findings could exist (from home dir check) + for _, f := range result.Findings { + assert.NotEqual(t, "known compromised", f.Title) + } +} + +func TestCheckCredentialFiles(t *testing.T) { + creds := checkCredentialFiles() + require.NotEmpty(t, creds, "should always return credential list") + + // Verify all expected paths are present + paths := map[string]bool{} + for _, c := range creds { + paths[c.Path] = true + assert.NotEmpty(t, c.Guidance) + } + assert.True(t, paths["~/.ssh/id_rsa"]) + assert.True(t, paths["~/.aws/credentials"]) + assert.True(t, paths["~/.kube/config"]) +} + +func TestParseMetadata(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "METADATA") + require.NoError(t, os.WriteFile(path, []byte( + "Metadata-Version: 2.1\nName: Flask\nVersion: 3.0.1\nSummary: Web framework\n\nFull description here.\n", + ), 0644)) + + pkg := parseMetadata(path) + assert.Equal(t, "flask", pkg.Name) // lowercased + assert.Equal(t, "3.0.1", pkg.Version) +} + +func TestCheckMultipleCompromised(t *testing.T) { + dir := t.TempDir() + + // Compromised package + d1 := filepath.Join(dir, "litellm-1.82.7.dist-info") + require.NoError(t, os.Mkdir(d1, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(d1, "METADATA"), []byte( + "Name: litellm\nVersion: 1.82.7\n", + ), 0644)) + + // Malicious .pth + require.NoError(t, os.WriteFile(filepath.Join(dir, "litellm_init.pth"), []byte( + "import subprocess; subprocess.Popen(['python', '-c', 'malware'])\n", + ), 0644)) + + result, err := Check(CheckOptions{Path: dir}) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(result.Findings), 2, "should find both package and .pth") +} diff --git a/internal/incident/cleaner.go b/internal/incident/cleaner.go new file mode 100644 index 0000000..6ff2f39 --- /dev/null +++ b/internal/incident/cleaner.go @@ -0,0 +1,206 @@ +package incident + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// CleanOptions configures a clean run. +type CleanOptions struct { + DryRun bool + PurgeCaches bool + CheckOpts CheckOptions +} + +// CleanAction describes a single cleanup action. +type CleanAction struct { + Type string `json:"type"` // "uninstall", "delete", "disable", "purge" + Target string `json:"target"` // what gets removed + Done bool `json:"done"` + Error string `json:"error,omitempty"` +} + +// CleanResult holds the outcome of a clean run. +type CleanResult struct { + QuarantineDir string `json:"quarantine_dir"` + Actions []CleanAction `json:"actions"` + Credentials []CredentialFile `json:"credentials"` + DryRun bool `json:"dry_run"` +} + +// Clean detects and removes compromised packages and persistence artifacts. +func Clean(opts CleanOptions) (*CleanResult, error) { + checkResult, err := Check(opts.CheckOpts) + if err != nil { + return nil, err + } + + if len(checkResult.Findings) == 0 { + return &CleanResult{ + DryRun: opts.DryRun, + Credentials: checkResult.Credentials, + }, nil + } + + ts := time.Now().Format("2006-01-02T150405") + quarantine := filepath.Join(os.TempDir(), "aguara-quarantine", ts) + + result := &CleanResult{ + QuarantineDir: quarantine, + DryRun: opts.DryRun, + Credentials: checkResult.Credentials, + } + + for _, f := range checkResult.Findings { + switch { + case strings.Contains(f.Title, "known compromised package"): + // Extract package name from title ("litellm 1.82.8 is a known...") + parts := strings.SplitN(f.Title, " ", 2) + pkgName := parts[0] + action := CleanAction{ + Type: "uninstall", + Target: pkgName, + } + if !opts.DryRun { + action.Done, action.Error = uninstallPackage(pkgName) + } + result.Actions = append(result.Actions, action) + + case strings.Contains(f.Title, "contains executable code") && strings.HasSuffix(f.Path, ".pth"): + action := CleanAction{ + Type: "delete", + Target: f.Path, + } + if !opts.DryRun { + action.Done, action.Error = quarantineFile(f.Path, quarantine) + } + result.Actions = append(result.Actions, action) + + case strings.Contains(f.Title, "Persistence artifact"): + path := f.Path + info, err := os.Stat(path) + if err != nil { + continue + } + + // Disable systemd service if applicable + if strings.HasSuffix(path, ".service") { + svcName := filepath.Base(path) + action := CleanAction{ + Type: "disable", + Target: "systemctl --user disable " + svcName, + } + if !opts.DryRun { + action.Done, action.Error = disableService(svcName) + } + result.Actions = append(result.Actions, action) + } + + action := CleanAction{ + Type: "delete", + Target: path, + } + if !opts.DryRun { + if info.IsDir() { + action.Done, action.Error = quarantineDir(path, quarantine) + } else { + action.Done, action.Error = quarantineFile(path, quarantine) + } + } + result.Actions = append(result.Actions, action) + + case strings.Contains(f.Title, "Cached compromised"): + if opts.PurgeCaches { + action := CleanAction{ + Type: "purge", + Target: f.Path, + } + if !opts.DryRun { + action.Done, action.Error = removeFile(f.Path) + } + result.Actions = append(result.Actions, action) + } + } + } + + // Purge pip/uv caches if requested + if opts.PurgeCaches && !opts.DryRun { + if path, err := exec.LookPath("pip"); err == nil { + _ = exec.Command(path, "cache", "purge").Run() + } + } + + return result, nil +} + +func uninstallPackage(name string) (bool, string) { + // Try uv first, then pip + for _, tool := range []string{"uv", "pip"} { + path, err := exec.LookPath(tool) + if err != nil { + continue + } + var cmd *exec.Cmd + if tool == "uv" { + cmd = exec.Command(path, "pip", "uninstall", name, "--yes") + } else { + cmd = exec.Command(path, "uninstall", name, "-y") + } + out, err := cmd.CombinedOutput() + if err != nil { + return false, fmt.Sprintf("%s uninstall failed: %s", tool, strings.TrimSpace(string(out))) + } + return true, "" + } + return false, "neither pip nor uv found in PATH" +} + +func quarantineFile(src, quarantineDir string) (bool, string) { + if err := os.MkdirAll(quarantineDir, 0700); err != nil { + return false, err.Error() + } + dst := filepath.Join(quarantineDir, filepath.Base(src)) + if err := os.Rename(src, dst); err != nil { + // Cross-device? Copy then delete. + data, readErr := os.ReadFile(src) + if readErr != nil { + return false, readErr.Error() + } + if writeErr := os.WriteFile(dst, data, 0600); writeErr != nil { + return false, writeErr.Error() + } + _ = os.Remove(src) + } + return true, "" +} + +func quarantineDir(src, quarantineDir string) (bool, string) { + dst := filepath.Join(quarantineDir, filepath.Base(src)) + if err := os.MkdirAll(quarantineDir, 0700); err != nil { + return false, err.Error() + } + if err := os.Rename(src, dst); err != nil { + return false, err.Error() + } + return true, "" +} + +func disableService(name string) (bool, string) { + cmd := exec.Command("systemctl", "--user", "disable", name) + out, err := cmd.CombinedOutput() + if err != nil { + return false, strings.TrimSpace(string(out)) + } + return true, "" +} + +func removeFile(path string) (bool, string) { + if err := os.Remove(path); err != nil { + return false, err.Error() + } + return true, "" +} diff --git a/internal/incident/cleaner_test.go b/internal/incident/cleaner_test.go new file mode 100644 index 0000000..85ec4e4 --- /dev/null +++ b/internal/incident/cleaner_test.go @@ -0,0 +1,102 @@ +package incident + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanDryRunNoChanges(t *testing.T) { + dir := t.TempDir() + + // Create a malicious .pth + pth := filepath.Join(dir, "evil.pth") + require.NoError(t, os.WriteFile(pth, []byte("import os; os.system('bad')"), 0644)) + + result, err := Clean(CleanOptions{ + DryRun: true, + CheckOpts: CheckOptions{Path: dir}, + }) + require.NoError(t, err) + assert.True(t, result.DryRun) + + // File should still exist (dry run) + _, err = os.Stat(pth) + assert.NoError(t, err, "dry run should not delete files") +} + +func TestCleanQuarantinesFile(t *testing.T) { + dir := t.TempDir() + + pth := filepath.Join(dir, "evil.pth") + require.NoError(t, os.WriteFile(pth, []byte("import subprocess; subprocess.Popen(['evil'])"), 0644)) + + result, err := Clean(CleanOptions{ + DryRun: false, + CheckOpts: CheckOptions{Path: dir}, + }) + require.NoError(t, err) + + // File should be removed from original location + _, err = os.Stat(pth) + assert.True(t, os.IsNotExist(err), "file should be quarantined (removed from original)") + + // Should be in quarantine + assert.NotEmpty(t, result.QuarantineDir) + quarantined := filepath.Join(result.QuarantineDir, "evil.pth") + _, err = os.Stat(quarantined) + assert.NoError(t, err, "file should exist in quarantine dir") +} + +func TestCleanNothingFound(t *testing.T) { + dir := t.TempDir() + + result, err := Clean(CleanOptions{ + CheckOpts: CheckOptions{Path: dir}, + }) + require.NoError(t, err) + assert.Empty(t, result.Actions, "clean env should have no actions") +} + +func TestCleanReportsCredentials(t *testing.T) { + dir := t.TempDir() + + // Even with nothing to clean, credentials should be reported + result, err := Clean(CleanOptions{ + CheckOpts: CheckOptions{Path: dir}, + }) + require.NoError(t, err) + assert.NotEmpty(t, result.Credentials) +} + +func TestCleanActionTypes(t *testing.T) { + dir := t.TempDir() + + // Compromised package + d := filepath.Join(dir, "litellm-1.82.8.dist-info") + require.NoError(t, os.Mkdir(d, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(d, "METADATA"), []byte( + "Name: litellm\nVersion: 1.82.8\n", + ), 0644)) + + // Malicious .pth + pth := filepath.Join(dir, "litellm_init.pth") + require.NoError(t, os.WriteFile(pth, []byte("import os; os.system('bad')"), 0644)) + + // Dry run to inspect actions without side effects + result, err := Clean(CleanOptions{ + DryRun: true, + CheckOpts: CheckOptions{Path: dir}, + }) + require.NoError(t, err) + + types := map[string]bool{} + for _, a := range result.Actions { + types[a.Type] = true + } + assert.True(t, types["uninstall"], "should have uninstall action for compromised package") + assert.True(t, types["delete"], "should have delete action for malicious .pth") +} diff --git a/internal/incident/compromised.go b/internal/incident/compromised.go new file mode 100644 index 0000000..199d9fb --- /dev/null +++ b/internal/incident/compromised.go @@ -0,0 +1,39 @@ +// Package incident provides detection and cleanup of compromised Python +// packages, malicious .pth files, and persistence artifacts. +package incident + +// CompromisedPackage describes a known-bad package+version combination. +type CompromisedPackage struct { + Name string `json:"name"` + Versions []string `json:"versions"` + Advisory string `json:"advisory"` + Date string `json:"date"` + Summary string `json:"summary"` +} + +// KnownCompromised is the embedded list of known compromised packages. +// Updated with each Aguara release. +var KnownCompromised = []CompromisedPackage{ + { + Name: "litellm", + Versions: []string{"1.82.7", "1.82.8"}, + Advisory: "PYSEC-2026-litellm", + Date: "2026-03-24", + Summary: "Malicious .pth file exfiltrates credentials (SSH, cloud, K8s) and installs backdoor with systemd persistence", + }, +} + +// IsCompromised checks if a package name+version is in the known-bad list. +func IsCompromised(name, version string) *CompromisedPackage { + for i := range KnownCompromised { + if KnownCompromised[i].Name != name { + continue + } + for _, v := range KnownCompromised[i].Versions { + if v == version { + return &KnownCompromised[i] + } + } + } + return nil +}