Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions cmd/aguara/commands/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,25 @@ import (
)

var (
flagCheckPath string
flagCheckIncludeCaches bool
flagCheckPath string
)

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.`,
files, persistence backdoors, and pip/uv/npx caches. 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,
Path: flagCheckPath,
})
if err != nil {
return err
Expand Down
4 changes: 2 additions & 2 deletions cmd/aguara/commands/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ func runClean(cmd *cobra.Command, args []string) error {

// Confirm unless --yes
if !flagCleanYes {
fmt.Print("Proceed with cleanup? [y/N] ")
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" {
if answer == "n" || answer == "no" {
fmt.Println("Aborted.")
return nil
}
Expand Down
56 changes: 44 additions & 12 deletions internal/incident/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,8 @@ func Check(opts CheckOptions) (*CheckResult, error) {
// 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()...)
}
// 5. Always check pip/uv/npx caches for compromised packages
result.Findings = append(result.Findings, checkCaches()...)

return result, nil
}
Expand Down Expand Up @@ -312,7 +310,7 @@ func checkCredentialFiles() []CredentialFile {
return creds
}

// checkCaches looks for compromised packages in pip/uv cache dirs.
// checkCaches looks for compromised packages and malicious files in pip/uv/npx cache dirs.
func checkCaches() []Finding {
home, err := os.UserHomeDir()
if err != nil {
Expand All @@ -321,29 +319,63 @@ func checkCaches() []Finding {

var findings []Finding
cacheDirs := []string{
filepath.Join(home, ".cache/pip/wheels"),
filepath.Join(home, ".cache/uv"),
filepath.Join(home, ".cache/pip/wheels"),
filepath.Join(home, ".cache/pip/http"),
filepath.Join(home, ".npm/_npx"),
filepath.Join(home, "Library/Caches/pip"), // macOS
}

seen := make(map[string]bool) // deduplicate findings by path
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())
name := d.Name()

// Check .pth files for executable content
if !d.IsDir() && strings.HasSuffix(name, ".pth") {
if pthFindings := checkPthFile(path); len(pthFindings) > 0 && !seen[path] {
seen[path] = true
findings = append(findings, pthFindings...)
}
return nil
}

// Check METADATA in dist-info dirs for compromised versions
if d.IsDir() && strings.HasSuffix(name, ".dist-info") {
metaPath := filepath.Join(path, "METADATA")
pkg := parseMetadata(metaPath)
if pkg.Name != "" {
if cp := IsCompromised(pkg.Name, pkg.Version); cp != nil && !seen[path] {
seen[path] = true
findings = append(findings, Finding{
Severity: SevCritical,
Title: fmt.Sprintf("Cached compromised package: %s %s (%s)", cp.Name, pkg.Version, cp.Advisory),
Detail: cp.Summary,
Path: path,
Remediation: "Run 'aguara clean --purge-caches' to remove cached packages",
})
}
}
return filepath.SkipDir
}

// Filename-based check for cache artifacts
base := strings.ToLower(name)
for _, cp := range KnownCompromised {
if strings.Contains(base, cp.Name) {
for _, v := range cp.Versions {
if strings.Contains(base, v) {
if strings.Contains(base, v) && !seen[path] {
seen[path] = true
findings = append(findings, Finding{
Severity: SevWarning,
Title: fmt.Sprintf("Cached compromised package: %s %s", cp.Name, v),
Path: path,
Severity: SevWarning,
Title: fmt.Sprintf("Cached compromised artifact: %s %s", cp.Name, v),
Path: path,
Remediation: "Run 'aguara clean --purge-caches' to remove cached packages",
})
}
Expand Down
57 changes: 57 additions & 0 deletions internal/rules/builtin/mcp-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,60 @@ examples:
false_positive:
- "\"args\": [\"run\", \"--network=none\", \"mcp-server\"]"
- "Use an isolated network for the container"
---
id: MCPCFG_012
name: "uvx MCP server without version pin"
description: "Detects MCP server configs using uvx or uv run to execute packages without version pinning. uvx auto-downloads the latest version from PyPI on every run. If a package is compromised, uvx pulls the malicious version automatically."
severity: HIGH
category: mcp-config
targets: ["*.json", "*.md", "*.txt"]
match_mode: any
remediation: "Pin uvx packages to exact versions: uvx run some-package@1.2.3. Or use a lockfile with hashes."
patterns:
- type: regex
value: "(?i)[\"']command[\"']\\s*:\\s*[\"'](uvx|uv)[\"']"
- type: regex
value: "(?i)claude\\s+mcp\\s+add\\s+\\S+\\s+--\\s+uvx\\s+"
- type: regex
value: "(?i)uvx\\s+run\\s+[a-zA-Z][a-zA-Z0-9_-]+"
exclude_patterns:
- type: regex
value: "(?i)uvx\\s+run\\s+\\S+@\\d"
- type: regex
value: "(?i)\\S+@\\d+\\.\\d+"
examples:
true_positive:
- '"command": "uvx", "args": ["litellm", "--proxy"]'
- '"command": "uv", "args": ["run", "some-mcp-server"]'
- "claude mcp add myserver -- uvx some-mcp-server"
- "uvx run mcp-server-github"
false_positive:
- '"command": "python", "args": ["server.py"]'
- "uvx run some-package@2.0.0 --flag"
---
id: MCPCFG_013
name: "pip install without hash verification in MCP server setup"
description: "Detects pip install commands in MCP server setup instructions that install specific packages without --require-hashes, allowing compromised versions from PyPI."
severity: MEDIUM
category: mcp-config
targets: ["*.md", "*.txt", "*.sh"]
match_mode: any
remediation: "Use pip install --require-hashes -r requirements.txt with a lockfile that includes SHA-256 hashes for every package."
patterns:
- type: regex
value: "(?i)pip\\s+install\\s+[a-zA-Z][a-zA-Z0-9_-]*(\\s+[a-zA-Z][a-zA-Z0-9_-]*)*\\s*$"
exclude_patterns:
- type: regex
value: "(?i)--require-hashes"
- type: regex
value: "(?i)^\\s*#"
- type: regex
value: "(?i)(pip|uv)\\s+install\\s+-r\\s+"
examples:
true_positive:
- "pip install litellm mcp-server-tools"
- "pip install some-mcp-package"
false_positive:
- "pip install --require-hashes -r requirements.txt"
- "pip install -r requirements.txt"
- "# pip install example-package"
Loading