diff --git a/.gitignore b/.gitignore index 83c903b..d0993ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binaries devsecops +devsecops.exe devsecops-* !demo/devsecops-* diff --git a/Makefile b/Makefile index 63f1d85..14dec59 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,16 @@ # Makefile MODULE_PATH := github.com/edgarpsda/devsecops-kit -VERSION ?= 0.4.1 +VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo development) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +LDFLAGS := -X $(MODULE_PATH)/cli/cmd.version=$(VERSION) -X $(MODULE_PATH)/cli/cmd.commit=$(COMMIT) -X $(MODULE_PATH)/cli/cmd.date=$(DATE) BINARY_NAME := devsecops .PHONY: build build: - go build -ldflags "-X $(MODULE_PATH)/cli/cmd.version=$(VERSION)" -o $(BINARY_NAME) ./cmd/devsecops + go build -buildvcs=false -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) ./cmd/devsecops .PHONY: test test: @@ -20,8 +23,8 @@ lint: # Cross-compilation examples for releases .PHONY: build-linux-amd64 build-linux-amd64: - GOOS=linux GOARCH=amd64 go build -ldflags "-X $(MODULE_PATH)/cli/cmd.version=$(VERSION)" -o $(BINARY_NAME)-linux-amd64 ./cmd/devsecops + GOOS=linux GOARCH=amd64 go build -buildvcs=false -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-linux-amd64 ./cmd/devsecops .PHONY: build-darwin-arm64 build-darwin-arm64: - GOOS=darwin GOARCH=arm64 go build -ldflags "-X $(MODULE_PATH)/cli/cmd.version=$(VERSION)" -o $(BINARY_NAME)-darwin-arm64 ./cmd/devsecops + GOOS=darwin GOARCH=arm64 go build -buildvcs=false -ldflags "$(LDFLAGS)" -o $(BINARY_NAME)-darwin-arm64 ./cmd/devsecops diff --git a/README.md b/README.md index 761e846..e353b69 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,28 @@ cd devsecops-kit go build -o devsecops ./cmd/devsecops/ ``` +For release-style builds, inject Git metadata with ldflags: + +```bash +go build -buildvcs=false -ldflags "\ + -X github.com/edgarpsda/devsecops-kit/cli/cmd.version=$(git describe --tags --always) \ + -X github.com/edgarpsda/devsecops-kit/cli/cmd.commit=$(git rev-parse --short HEAD) \ + -X github.com/edgarpsda/devsecops-kit/cli/cmd.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o devsecops ./cmd/devsecops/ +``` + +Or use the included build helpers: + +```bash +make build +``` + +On Windows PowerShell: + +```powershell +.\build.ps1 +``` + ### Scanner dependencies The CLI orchestrates external tools that must be installed separately: @@ -180,6 +202,7 @@ The CLI orchestrates external tools that must be installed separately: | Gitleaks | [releases page](https://github.com/gitleaks/gitleaks/releases) | | Trivy | [install script](https://aquasecurity.github.io/trivy/latest/getting-started/installation/) | | Checkov | `pip install checkov` (optional) | +| Snyk | [Snyk CLI](https://docs.snyk.io/snyk-cli/install-or-update-the-snyk-cli) (optional, for auto remediation) | | Ollama | [ollama.com](https://ollama.com) (optional, for AI suggestions) | Run `devsecops diagnose` to check which tools are available. @@ -209,7 +232,7 @@ devsecops diagnose | **0.4.1** | HTML reports, progress UI | βœ… Released | | **0.5.0** | Python/Java detection, SBOM, SARIF output, license compliance | βœ… Released | | **0.6.0** | Multi-CI (GitLab/Bitbucket), IaC scanning (Checkov), AI fix suggestions | βœ… Released | -| **0.7.0** | Vulnerability trending, EPSS/KEV scoring, TruffleHog integration | πŸ“‹ Planned | +| **0.7.0** | Security Auto Remediation MVP, Snyk remediation provider, Semgrep hardening, Git metadata builds | βœ… Released | ## Contributing diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..01db9d8 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,34 @@ +$ErrorActionPreference = "Stop" + +$modulePath = "github.com/edgarpsda/devsecops-kit" +$repoRoot = (Get-Location).Path.Replace("\", "/") + +function Get-GitValue { + param ( + [string[]]$Arguments, + [string]$Fallback + ) + + try { + $value = & git -c "safe.directory=$repoRoot" @Arguments 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($value)) { + return $Fallback + } + return $value.Trim() + } catch { + return $Fallback + } +} + +$version = Get-GitValue -Arguments @("describe", "--tags", "--always") -Fallback "development" +$commit = Get-GitValue -Arguments @("rev-parse", "--short", "HEAD") -Fallback "none" +$date = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + +$ldflags = "-X $modulePath/cli/cmd.version=$version -X $modulePath/cli/cmd.commit=$commit -X $modulePath/cli/cmd.date=$date" + +Write-Host "Building DevSecOps Kit" +Write-Host "Version: $version" +Write-Host "Commit : $commit" +Write-Host "Built : $date" + +go build -buildvcs=false -ldflags $ldflags -o devsecops.exe ./cmd/devsecops diff --git a/cli/ai/suggestions.go b/cli/ai/suggestions.go index e669014..a6b2969 100644 --- a/cli/ai/suggestions.go +++ b/cli/ai/suggestions.go @@ -83,9 +83,17 @@ func (c *Client) EnrichFindings(findings []scanners.Finding) { } } +// Complete sends a prompt to the configured AI provider and returns the raw response. +func (c *Client) Complete(prompt string) (string, error) { + return c.complete(prompt) +} + func (c *Client) getSuggestion(f *scanners.Finding) (string, error) { prompt := buildPrompt(f) + return c.complete(prompt) +} +func (c *Client) complete(prompt string) (string, error) { switch c.cfg.Provider { case "openai": return c.callOpenAI(prompt) diff --git a/cli/cmd/remediate.go b/cli/cmd/remediate.go new file mode 100644 index 0000000..bd488b9 --- /dev/null +++ b/cli/cmd/remediate.go @@ -0,0 +1,771 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/edgarpsda/devsecops-kit/cli/ai" + "github.com/edgarpsda/devsecops-kit/cli/config" + "github.com/edgarpsda/devsecops-kit/cli/detectors" + "github.com/edgarpsda/devsecops-kit/cli/scanners" + "github.com/edgarpsda/devsecops-kit/internal/remediation" +) + +var ( + remediatePlan bool + remediateConfigPath string + remediateProvider string +) + +var remediateCmd = &cobra.Command{ + Use: "remediate", + Short: "Run or plan security remediation workflow", + Long: `Run the Security Auto Remediation workflow. + +By default this command runs a vertical-slice MVP: +- Snyk Open Source scan +- AI patch generation for HIGH/CRITICAL findings +- apply patch on a new Git branch +- project test command +- Snyk re-scan + +It does not commit, push, or open pull requests.`, + RunE: func(cmd *cobra.Command, args []string) error { + return runRemediate() + }, +} + +func init() { + rootCmd.AddCommand(remediateCmd) + + remediateCmd.Flags().BoolVar(&remediatePlan, "plan", false, "Show the remediation dry run plan") + remediateCmd.Flags().StringVar(&remediateConfigPath, "config", "security-config.yml", "Path to security-config.yml") + remediateCmd.Flags().StringVar(&remediateProvider, "provider", "snyk", "Remediation provider: snyk, semgrep") +} + +func runRemediate() error { + dir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + secConfig, err := config.LoadConfig(filepath.Join(dir, remediateConfigPath)) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + if !remediatePlan { + switch strings.ToLower(remediateProvider) { + case "snyk": + return runSnykRemediationMVP(dir, secConfig) + case "semgrep": + return runRemediationMVP(dir, secConfig) + default: + return fmt.Errorf("unsupported remediation provider: %s", remediateProvider) + } + } + + engine := remediation.NewEngineWithValidation( + remediation.Options{DryRun: true}, + nil, + nil, + nil, + nil, + nil, + nil, + nil, + ) + + printRemediationPlan(dir, secConfig, engine) + return nil +} + +type remediationAttempt struct { + Finding scanners.Finding + Status string + Reason string + ModifiedFiles []string +} + +type fileBackup struct { + Path string + Data []byte + Exists bool +} + +func runRemediationMVP(dir string, secConfig *config.SecurityConfig) error { + projectInfo, err := detectors.DetectProject(dir) + if err != nil { + return fmt.Errorf("failed to detect project: %w", err) + } + + if !secConfig.AI.Enabled { + return fmt.Errorf("AI remediation requires ai.enabled=true in %s", remediateConfigPath) + } + + aiClient := ai.NewClient(ai.Config{ + Enabled: true, + Provider: secConfig.AI.Provider, + Model: secConfig.AI.Model, + Endpoint: secConfig.AI.Endpoint, + APIKey: aiAPIKey(secConfig), + }) + + fmt.Println("πŸ” Running Semgrep scan...") + report, err := runSemgrepOnly(dir, secConfig) + if err != nil { + return err + } + + targets := highCriticalFindings(report.AllFindings) + if len(targets) == 0 { + fmt.Println("βœ… No HIGH or CRITICAL Semgrep findings found. Nothing to remediate.") + return nil + } + + originalBranch, err := gitOutput(dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return fmt.Errorf("failed to detect current git branch: %w", err) + } + + if dirty, err := gitHasChanges(dir); err != nil { + return err + } else if dirty { + return fmt.Errorf("working tree has existing changes; commit, stash, or clean them before auto remediation") + } + + fmt.Println("πŸ§ͺ Running baseline project validation...") + if err := runProjectTests(dir, projectInfo); err != nil { + return fmt.Errorf("baseline validation failed; fix the project tests before auto remediation:\n%w", err) + } + + branchName := "security/remediation-" + time.Now().Format("20060102-150405") + if _, err := gitOutput(dir, "checkout", "-b", branchName); err != nil { + return fmt.Errorf("failed to create remediation branch: %w", err) + } + + summary := struct { + Scanned int + Fixed int + Failed int + ModifiedFiles map[string]bool + Attempts []remediationAttempt + Remaining int + Branch string + }{ + Scanned: len(report.AllFindings), + ModifiedFiles: make(map[string]bool), + Branch: branchName, + } + + for _, finding := range targets { + fmt.Printf("\nπŸ›  Remediating %s (%s) in %s:%d\n", finding.RuleID, finding.Severity, finding.File, finding.Line) + + contextText, err := codeContext(dir, finding.File, finding.Line, 20) + if err != nil { + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: err.Error()}) + fmt.Printf("❌ Failed to read context: %v\n", err) + continue + } + + prompt := buildRemediationPrompt(projectInfo, finding, contextText) + response, err := aiClient.Complete(prompt) + if err != nil { + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: err.Error()}) + fmt.Printf("❌ AI provider failed: %v\n", err) + continue + } + + backups, err := applyAIPatchWithRepair(dir, aiClient, prompt, response) + if err != nil { + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: err.Error()}) + fmt.Printf("❌ Failed to apply patch: %v\n", err) + continue + } + + modifiedFiles, err := gitChangedFiles(dir) + if err != nil { + _ = restoreBackups(backups) + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: err.Error()}) + fmt.Printf("❌ Failed to detect modified files: %v\n", err) + continue + } + + fmt.Println("πŸ§ͺ Running project validation...") + if err := runProjectTests(dir, projectInfo); err != nil { + _ = restoreBackups(backups) + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: "build/tests failed: " + err.Error(), ModifiedFiles: modifiedFiles}) + fmt.Printf("❌ Build/tests failed. Changes restored: %v\n", err) + continue + } + + fmt.Println("πŸ” Re-running Semgrep...") + rescan, err := runSemgrepOnly(dir, secConfig) + if err != nil { + _ = restoreBackups(backups) + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: "rescan failed: " + err.Error(), ModifiedFiles: modifiedFiles}) + fmt.Printf("❌ Semgrep re-scan failed. Changes restored: %v\n", err) + continue + } + + if findingStillPresent(finding, rescan.AllFindings) { + _ = restoreBackups(backups) + summary.Failed++ + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FAILED", Reason: "finding still present after remediation", ModifiedFiles: modifiedFiles}) + fmt.Println("❌ Finding still present. Changes restored.") + continue + } + + summary.Fixed++ + for _, file := range modifiedFiles { + summary.ModifiedFiles[file] = true + } + summary.Attempts = append(summary.Attempts, remediationAttempt{Finding: finding, Status: "FIXED", ModifiedFiles: modifiedFiles}) + fmt.Println("βœ… Finding fixed.") + } + + finalReport, err := runSemgrepOnly(dir, secConfig) + if err == nil { + summary.Remaining = len(finalReport.AllFindings) + } + + if summary.Fixed == 0 { + _, _ = gitOutput(dir, "checkout", originalBranch) + _, _ = gitOutput(dir, "branch", "-D", branchName) + summary.Branch = "(deleted - no successful fixes)" + } + + printRemediationSummary(summary.Scanned, summary.Fixed, summary.Failed, sortedMapKeys(summary.ModifiedFiles), summary.Remaining, summary.Branch) + return nil +} + +func printRemediationPlan(projectDir string, secConfig *config.SecurityConfig, engine *remediation.Engine) { + fmt.Println("πŸ›  Security Auto Remediation Plan") + fmt.Println("--------------------------------") + fmt.Printf("Project root: %s\n", projectDir) + fmt.Printf("Mode: dry run (no files, branches, commits, pushes, or pull requests will be created)\n") + fmt.Printf("Engine dry run: %t\n\n", engine.Options().DryRun) + + fmt.Println("1. Findings encontrados") + fmt.Println(" - 0 findings loaded in this phase") + fmt.Println(" - Dry run does not execute scanners yet") + fmt.Println(" - Future finding providers: Semgrep, Trivy, Gitleaks, Checkov, Snyk, SARIF") + fmt.Println() + + fmt.Println("2. Recomendaciones disponibles") + fmt.Println(" - 0 recommendations loaded in this phase") + fmt.Println(" - Future recommendation providers: Snyk, GitHub Security Advisories, OSV, NVD") + fmt.Println(" - AI patch generation is not enabled in this phase") + fmt.Println() + + fmt.Println("3. Archivos potencialmente modificados") + fmt.Println(" - No files are inspected or modified by --plan") + fmt.Println(" - Future patches will target files referenced by normalized findings and recommendations") + fmt.Println() + + fmt.Println("4. Validaciones que se ejecutarΓ­an") + for _, check := range remediationPlanValidations() { + fmt.Printf(" - %s\n", check) + } + fmt.Println() + + fmt.Println("5. Scanners que volverΓ­an a correr") + for _, scanner := range remediationPlanScanners(secConfig) { + fmt.Printf(" - %s\n", scanner) + } + fmt.Println() + + fmt.Println("6. Git") + fmt.Println(" - Branch creation: skipped in --plan") + fmt.Println(" - Diff generation: planned for future phases") + fmt.Println(" - Rollback/restore: planned for future phases") +} + +func remediationPlanValidations() []string { + return []string{ + "Build", + "Unit Tests", + "Integration Tests", + "Security Re-scan", + } +} + +func remediationPlanScanners(secConfig *config.SecurityConfig) []string { + var scanners []string + if secConfig.Tools.Semgrep { + scanners = append(scanners, "Semgrep") + } + if secConfig.Tools.Trivy { + scanners = append(scanners, "Trivy") + } + if secConfig.Tools.Gitleaks { + scanners = append(scanners, "Gitleaks") + } + if secConfig.Tools.Checkov { + scanners = append(scanners, "Checkov") + } + if secConfig.Licenses.Enabled { + scanners = append(scanners, "License scan") + } + if len(scanners) == 0 { + scanners = append(scanners, "None configured") + } + return scanners +} + +func runSemgrepOnly(dir string, secConfig *config.SecurityConfig) (*scanners.ScanReport, error) { + options := scanners.ScanOptions{ + EnableSemgrep: true, + EnableGitleaks: false, + EnableTrivy: false, + EnableCheckov: false, + EnableLicenses: false, + ExcludePaths: secConfig.ExcludePaths, + FailOnThresholds: secConfig.FailOn, + Verbose: false, + } + + orchestrator := scanners.NewOrchestrator(dir, options) + report, err := orchestrator.Run() + if err != nil { + return nil, fmt.Errorf("semgrep scan failed: %w", err) + } + return report, nil +} + +func highCriticalFindings(findings []scanners.Finding) []scanners.Finding { + var targets []scanners.Finding + for _, finding := range findings { + if finding.Tool != "semgrep" { + continue + } + if finding.Severity == "HIGH" || finding.Severity == "CRITICAL" { + targets = append(targets, finding) + } + } + return targets +} + +func aiAPIKey(secConfig *config.SecurityConfig) string { + if secConfig.AI.APIKey != "" { + return secConfig.AI.APIKey + } + switch secConfig.AI.Provider { + case "openai": + return os.Getenv("OPENAI_API_KEY") + case "anthropic": + return os.Getenv("ANTHROPIC_API_KEY") + default: + return "" + } +} + +func buildRemediationPrompt(projectInfo *detectors.ProjectInfo, finding scanners.Finding, contextText string) string { + return fmt.Sprintf(`You are an expert secure code remediation agent. + +Project: +- Language: %s +- Framework: %s +- Package file: %s + +Security finding: +- Tool: %s +- Severity: %s +- Rule: %s +- File: %s +- Line: %d +- Message: %s + +Affected code and nearby context: +%s + +Strict instructions: +- Fix only this vulnerability. +- Do not modify unrelated business logic. +- Preserve existing behavior and compatibility. +- Make the smallest safe change. +- Return only a unified diff patch that can be applied with git apply. +- Do not include markdown fences, explanations, prose, or comments outside the patch. +- The patch must include file headers (diff --git or ---/+++). +`, projectInfo.Language, projectInfo.Framework, projectInfo.PackageFile, finding.Tool, finding.Severity, finding.RuleID, finding.File, finding.Line, finding.Message, contextText) +} + +func codeContext(root, relPath string, line, radius int) (string, error) { + path, err := safeJoin(root, relPath) + if err != nil { + return "", err + } + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read %s: %w", relPath, err) + } + + lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + if line <= 0 { + line = 1 + } + + start := line - radius + if start < 1 { + start = 1 + } + end := line + radius + if end > len(lines) { + end = len(lines) + } + + var b strings.Builder + fmt.Fprintf(&b, "--- %s:%d-%d ---\n", relPath, start, end) + for i := start; i <= end; i++ { + fmt.Fprintf(&b, "%4d | %s\n", i, lines[i-1]) + } + return b.String(), nil +} + +func safeJoin(root, relPath string) (string, error) { + if relPath == "" { + return "", fmt.Errorf("empty file path") + } + if filepath.IsAbs(relPath) { + return "", fmt.Errorf("absolute paths are not allowed: %s", relPath) + } + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + absPath, err := filepath.Abs(filepath.Join(absRoot, filepath.Clean(relPath))) + if err != nil { + return "", err + } + rel, err := filepath.Rel(absRoot, absPath) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("path escapes project root: %s", relPath) + } + return absPath, nil +} + +func extractUnifiedDiff(response string) (string, error) { + text := strings.TrimSpace(response) + if strings.Contains(text, "```") { + parts := strings.Split(text, "```") + for _, part := range parts { + part = strings.TrimSpace(strings.TrimPrefix(part, "diff")) + if strings.Contains(part, "diff --git ") || strings.HasPrefix(part, "--- ") { + text = strings.TrimSpace(part) + break + } + } + } + + if idx := strings.Index(text, "diff --git "); idx >= 0 { + return strings.TrimSpace(text[idx:]) + "\n", nil + } + if idx := strings.Index(text, "--- "); idx >= 0 { + return strings.TrimSpace(text[idx:]) + "\n", nil + } + + return "", fmt.Errorf("no unified diff found") +} + +func diffFiles(patch string) []string { + seen := make(map[string]bool) + for _, line := range strings.Split(patch, "\n") { + if strings.HasPrefix(line, "diff --git ") { + fields := strings.Fields(line) + if len(fields) >= 4 { + file := strings.TrimPrefix(fields[3], "b/") + seen[file] = true + } + continue + } + if strings.HasPrefix(line, "+++ b/") { + seen[strings.TrimPrefix(strings.TrimSpace(line), "+++ b/")] = true + } + } + return sortedMapKeys(seen) +} + +func backupFiles(root string, files []string) ([]fileBackup, error) { + if len(files) == 0 { + return nil, fmt.Errorf("patch did not identify modified files") + } + + backups := make([]fileBackup, 0, len(files)) + for _, file := range files { + path, err := safeJoin(root, file) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + backups = append(backups, fileBackup{Path: path, Exists: false}) + continue + } + return nil, err + } + backups = append(backups, fileBackup{Path: path, Data: data, Exists: true}) + } + return backups, nil +} + +func restoreBackups(backups []fileBackup) error { + for _, backup := range backups { + if !backup.Exists { + if err := os.Remove(backup.Path); err != nil && !os.IsNotExist(err) { + return err + } + continue + } + if err := os.WriteFile(backup.Path, backup.Data, 0o644); err != nil { + return err + } + } + return nil +} + +func gitApply(dir, patch string) error { + cmd := exec.Command("git", "apply", "--whitespace=nowarn") + cmd.Dir = dir + cmd.Stdin = strings.NewReader(patch) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return nil +} + +func applyAIPatchWithRepair(dir string, aiClient *ai.Client, prompt, response string) ([]fileBackup, error) { + patch, err := extractUnifiedDiff(response) + if err != nil { + repaired, repairErr := repairAIPatch(aiClient, prompt, response, "", err) + if repairErr != nil { + return nil, fmt.Errorf("AI response did not contain a usable patch: %w", err) + } + patch = repaired + } + + backups, err := backupFiles(dir, diffFiles(patch)) + if err != nil { + repaired, repairErr := repairAIPatch(aiClient, prompt, response, patch, err) + if repairErr != nil { + return nil, err + } + patch = repaired + backups, err = backupFiles(dir, diffFiles(patch)) + if err != nil { + return nil, err + } + } + + if err := gitApply(dir, patch); err != nil { + _ = restoreBackups(backups) + repaired, repairErr := repairAIPatch(aiClient, prompt, response, patch, err) + if repairErr != nil { + return nil, err + } + patch = repaired + backups, err = backupFiles(dir, diffFiles(patch)) + if err != nil { + return nil, err + } + if err := gitApply(dir, patch); err != nil { + _ = restoreBackups(backups) + return nil, err + } + } + + return backups, nil +} + +func repairAIPatch(aiClient *ai.Client, originalPrompt, response, patch string, applyErr error) (string, error) { + var b strings.Builder + b.WriteString(originalPrompt) + b.WriteString("\n\nThe previous response could not be applied as a patch.\n") + b.WriteString("Return only a corrected unified diff patch. Do not change the intended security fix.\n") + b.WriteString("The patch must be accepted by: git apply --whitespace=nowarn\n") + b.WriteString("Git/extraction error:\n") + b.WriteString(applyErr.Error()) + b.WriteString("\n\nPrevious AI response:\n") + b.WriteString(response) + if patch != "" && patch != response { + b.WriteString("\n\nExtracted patch:\n") + b.WriteString(patch) + } + + repaired, err := aiClient.Complete(b.String()) + if err != nil { + return "", err + } + return extractUnifiedDiff(repaired) +} + +func gitOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) + } + return strings.TrimSpace(string(output)), nil +} + +func gitHasChanges(dir string) (bool, error) { + output, err := gitOutput(dir, "status", "--porcelain") + if err != nil { + return false, fmt.Errorf("failed to inspect git status: %w", err) + } + return strings.TrimSpace(output) != "", nil +} + +func gitChangedFiles(dir string) ([]string, error) { + output, err := gitOutput(dir, "diff", "--name-only") + if err != nil { + return nil, err + } + var files []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line != "" { + files = append(files, line) + } + } + sort.Strings(files) + return files, nil +} + +func runProjectTests(dir string, projectInfo *detectors.ProjectInfo) error { + name, args, err := testCommand(dir, projectInfo) + if err != nil { + return err + } + + cmd := exec.Command(name, args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%w\n%s", err, commandOutputTail(output, 40)) + } + return nil +} + +func commandOutputTail(output []byte, maxLines int) string { + text := strings.TrimSpace(string(output)) + if text == "" { + return "" + } + lines := strings.Split(text, "\n") + if len(lines) <= maxLines { + return text + } + return strings.TrimSpace(strings.Join(lines[len(lines)-maxLines:], "\n")) +} + +func testCommand(dir string, projectInfo *detectors.ProjectInfo) (string, []string, error) { + switch projectInfo.Language { + case "java": + if strings.HasPrefix(projectInfo.PackageFile, "build.gradle") { + if runtime.GOOS == "windows" && fileExists(filepath.Join(dir, "gradlew.bat")) { + return filepath.Join(dir, "gradlew.bat"), []string{"test"}, nil + } + if fileExists(filepath.Join(dir, "gradlew")) { + return filepath.Join(dir, "gradlew"), []string{"test"}, nil + } + return "gradle", []string{"test"}, nil + } + if runtime.GOOS == "windows" && fileExists(filepath.Join(dir, "mvnw.cmd")) { + return filepath.Join(dir, "mvnw.cmd"), []string{"test"}, nil + } + if fileExists(filepath.Join(dir, "mvnw")) { + return filepath.Join(dir, "mvnw"), []string{"test"}, nil + } + return "mvn", []string{"test"}, nil + case "nodejs": + return "npm", []string{"test"}, nil + case "python": + return "pytest", nil, nil + case "golang": + return "go", []string{"test", "./..."}, nil + default: + return "", nil, fmt.Errorf("no validation command for language %s", projectInfo.Language) + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func findingStillPresent(original scanners.Finding, findings []scanners.Finding) bool { + for _, finding := range findings { + if finding.Tool != original.Tool { + continue + } + if finding.RuleID == original.RuleID && filepath.Clean(finding.File) == filepath.Clean(original.File) { + return true + } + } + return false +} + +func sortedMapKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func printRemediationSummary(scanned, fixed, failed int, modifiedFiles []string, remaining int, branch string) { + fmt.Println() + fmt.Println("--------------------------------------------------") + fmt.Println("Auto Remediation Summary") + fmt.Println() + fmt.Println("Scanned:") + fmt.Printf("%d findings\n\n", scanned) + fmt.Println("Fixed:") + fmt.Printf("%d\n\n", fixed) + fmt.Println("Failed:") + fmt.Printf("%d\n\n", failed) + fmt.Println("Modified files:") + if len(modifiedFiles) == 0 { + fmt.Println("(none)") + } else { + for _, file := range modifiedFiles { + fmt.Println(file) + } + } + fmt.Println() + fmt.Println("Remaining findings:") + fmt.Printf("%d\n\n", remaining) + fmt.Println("Branch:") + fmt.Println(branch) + fmt.Println() + if fixed > 0 { + fmt.Println("Ready for review.") + } else { + fmt.Println("No successful fixes were kept.") + } + fmt.Println("--------------------------------------------------") +} diff --git a/cli/cmd/remediate_snyk.go b/cli/cmd/remediate_snyk.go new file mode 100644 index 0000000..eb6fe5d --- /dev/null +++ b/cli/cmd/remediate_snyk.go @@ -0,0 +1,806 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" + + "github.com/edgarpsda/devsecops-kit/cli/ai" + "github.com/edgarpsda/devsecops-kit/cli/config" + "github.com/edgarpsda/devsecops-kit/cli/detectors" +) + +type snykScanResult struct { + ProjectName string `json:"projectName"` + Path string `json:"path"` + DisplayTargetFile string `json:"displayTargetFile"` + PackageManager string `json:"packageManager"` + TargetFile string `json:"targetFile"` + Vulnerabilities []snykVulnerability `json:"vulnerabilities"` +} + +type snykVulnerability struct { + ID string `json:"id"` + Title string `json:"title"` + Severity string `json:"severity"` + PackageName string `json:"packageName"` + Name string `json:"name"` + Version string `json:"version"` + PackageManager string `json:"packageManager"` + IsUpgradable bool `json:"isUpgradable"` + IsPatchable bool `json:"isPatchable"` + UpgradePath []interface{} `json:"upgradePath"` + NearestFixedInVersion string `json:"nearestFixedInVersion"` + FixedIn []string `json:"fixedIn"` + From []string `json:"from"` + Identifiers json.RawMessage `json:"identifiers"` + Description string `json:"description"` + Recommendation string `json:"recommendation"` + ProjectName string `json:"-"` + ProjectPath string `json:"-"` + TargetFile string `json:"-"` + ManifestFile string `json:"-"` +} + +type snykRemediationGroup struct { + ManifestFile string + PackageName string + Findings []snykVulnerability +} + +func runSnykRemediationMVP(dir string, secConfig *config.SecurityConfig) error { + if _, err := exec.LookPath("snyk"); err != nil { + return fmt.Errorf("snyk is not installed or not on PATH") + } + + projectInfo, err := detectors.DetectProject(dir) + if err != nil { + return fmt.Errorf("failed to detect project: %w", err) + } + + if !secConfig.AI.Enabled { + return fmt.Errorf("Snyk remediation requires ai.enabled=true in %s", remediateConfigPath) + } + + aiClient := ai.NewClient(ai.Config{ + Enabled: true, + Provider: secConfig.AI.Provider, + Model: secConfig.AI.Model, + Endpoint: secConfig.AI.Endpoint, + APIKey: aiAPIKey(secConfig), + }) + + fmt.Println("πŸ” Running Snyk scan...") + findings, err := runSnykScan(dir) + if err != nil { + return err + } + targets := snykHighCritical(findings) + if len(targets) == 0 { + fmt.Println("βœ… No HIGH or CRITICAL Snyk vulnerabilities found. Nothing to remediate.") + return nil + } + + originalBranch, err := gitOutput(dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return fmt.Errorf("failed to detect current git branch: %w", err) + } + + if dirty, err := gitHasChanges(dir); err != nil { + return err + } else if dirty { + return fmt.Errorf("working tree has existing changes; commit, stash, or clean them before auto remediation") + } + + fmt.Println("πŸ§ͺ Running baseline project validation...") + if err := runProjectTests(dir, projectInfo); err != nil { + return fmt.Errorf("baseline validation failed; fix the project tests before auto remediation:\n%w", err) + } + + branchName := "security/remediation-" + time.Now().Format("20060102-150405") + if _, err := gitOutput(dir, "checkout", "-b", branchName); err != nil { + return fmt.Errorf("failed to create remediation branch: %w", err) + } + + modified := make(map[string]bool) + fixed := 0 + failed := 0 + currentFindings := findings + groups := groupSnykFindings(targets) + + for _, group := range groups { + if !snykGroupStillPresent(group, currentFindings) { + fmt.Printf("\n⏭ Skipping %s in %s; already resolved by an earlier change.\n", group.PackageName, group.ManifestFile) + continue + } + + finding := group.Findings[0] + beforeCount := snykGroupPresentCount(group, currentFindings) + fmt.Printf("\nπŸ›  Remediating %s in %s (%d HIGH/CRITICAL findings)\n", group.PackageName, group.ManifestFile, beforeCount) + + contextText, err := snykManifestContext(dir, finding) + if err != nil { + failed += beforeCount + fmt.Printf("❌ Failed to read manifest context: %v\n", err) + continue + } + + backups, err := applySnykMavenFallback(dir, group) + if err != nil { + fmt.Printf("⚠ Maven fallback was not available, trying AI patch: %v\n", err) + prompt := buildSnykGroupRemediationPrompt(projectInfo, group, contextText) + response, aiErr := aiClient.Complete(prompt) + if aiErr != nil { + failed += beforeCount + fmt.Printf("❌ AI provider failed: %v\n", aiErr) + continue + } + backups, err = applyAIPatchWithRepair(dir, aiClient, prompt, response) + if err != nil { + failed += beforeCount + fmt.Printf("❌ Failed to apply patch: %v\n", err) + continue + } + } + + modifiedFiles, err := gitChangedFiles(dir) + if err != nil { + _ = restoreBackups(backups) + failed += beforeCount + fmt.Printf("❌ Failed to detect modified files: %v\n", err) + continue + } + + fmt.Println("πŸ§ͺ Running project validation...") + if err := runValidationForFiles(dir, projectInfo, modifiedFiles); err != nil { + _ = restoreBackups(backups) + failed += beforeCount + fmt.Printf("❌ Build/tests failed. Changes restored: %v\n", err) + continue + } + + fmt.Println("πŸ” Re-running Snyk...") + rescan, err := runSnykScan(dir) + if err != nil { + _ = restoreBackups(backups) + failed += beforeCount + fmt.Printf("❌ Snyk re-scan failed. Changes restored: %v\n", err) + continue + } + currentFindings = rescan + + remainingInGroup := snykGroupPresentCount(group, rescan) + resolved := beforeCount - remainingInGroup + if remainingInGroup > 0 { + if resolved == 0 { + _ = restoreBackups(backups) + failed += beforeCount + fmt.Printf("❌ %d finding(s) still present for %s. Changes restored.\n", remainingInGroup, group.PackageName) + continue + } + fixed += resolved + failed += remainingInGroup + for _, file := range modifiedFiles { + modified[file] = true + } + fmt.Printf("⚠ Fixed %d finding(s) for %s; %d remain.\n", resolved, group.PackageName, remainingInGroup) + continue + } + + fixed += resolved + for _, file := range modifiedFiles { + modified[file] = true + } + fmt.Printf("βœ… Fixed %d finding(s) for %s.\n", resolved, group.PackageName) + } + + remaining := 0 + if finalFindings, err := runSnykScan(dir); err == nil { + remaining = len(snykHighCritical(finalFindings)) + fixed = len(targets) - remaining + if fixed < 0 { + fixed = 0 + } + failed = remaining + } + + finalBranch := branchName + if fixed == 0 { + _, _ = gitOutput(dir, "checkout", originalBranch) + _, _ = gitOutput(dir, "branch", "-D", branchName) + finalBranch = "(deleted - no successful fixes)" + } + + printRemediationSummary(len(findings), fixed, failed, sortedMapKeys(modified), remaining, finalBranch) + return nil +} + +func runSnykScan(dir string) ([]snykVulnerability, error) { + cmd := exec.Command("snyk", "test", "--json", "--severity-threshold=high", "--show-vulnerable-paths=all", "--all-projects") + cmd.Dir = dir + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + switch exitErr.ExitCode() { + case 1: + // Snyk returns 1 when vulnerabilities are found. + default: + return nil, fmt.Errorf("snyk scan failed: %w\nstderr:\n%s", err, stderr.String()) + } + } else { + return nil, fmt.Errorf("snyk scan failed: %w\nstderr:\n%s", err, stderr.String()) + } + } + + results, err := parseSnykResults(stdout.Bytes()) + if err != nil { + return nil, fmt.Errorf("unable to parse Snyk JSON output: %w\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) + } + + var findings []snykVulnerability + for _, result := range results { + for _, vuln := range result.Vulnerabilities { + if vuln.PackageName == "" { + vuln.PackageName = vuln.Name + } + if vuln.PackageManager == "" { + vuln.PackageManager = result.PackageManager + } + vuln.ProjectName = result.ProjectName + vuln.ProjectPath = result.Path + vuln.TargetFile = snykTargetFile(result) + vuln.ManifestFile = snykManifestFile(dir, result) + findings = append(findings, vuln) + } + } + return findings, nil +} + +func parseSnykResults(data []byte) ([]snykScanResult, error) { + payload, err := jsonPayload(data) + if err != nil { + return nil, err + } + + var multi []snykScanResult + if err := json.Unmarshal(payload, &multi); err == nil { + return multi, nil + } + + var single snykScanResult + if err := json.Unmarshal(payload, &single); err == nil { + return []snykScanResult{single}, nil + } + + return nil, fmt.Errorf("unrecognized Snyk JSON shape") +} + +func jsonPayload(output []byte) ([]byte, error) { + output = bytes.TrimSpace(output) + if len(output) == 0 { + return nil, fmt.Errorf("stdout is empty") + } + for i, b := range output { + if b != '{' && b != '[' { + continue + } + var raw json.RawMessage + decoder := json.NewDecoder(bytes.NewReader(output[i:])) + if err := decoder.Decode(&raw); err == nil { + return raw, nil + } + } + return nil, fmt.Errorf("no valid JSON found in stdout") +} + +func snykHighCritical(findings []snykVulnerability) []snykVulnerability { + var targets []snykVulnerability + for _, finding := range findings { + switch strings.ToUpper(finding.Severity) { + case "HIGH", "CRITICAL": + targets = append(targets, finding) + } + } + return targets +} + +func groupSnykFindings(findings []snykVulnerability) []snykRemediationGroup { + seen := make(map[string]int) + var groups []snykRemediationGroup + + for _, finding := range findings { + manifest := snykManifestPath(finding) + packageName := finding.PackageName + if packageName == "" { + packageName = finding.Name + } + key := manifest + "\x00" + packageName + if idx, ok := seen[key]; ok { + groups[idx].Findings = append(groups[idx].Findings, finding) + continue + } + seen[key] = len(groups) + groups = append(groups, snykRemediationGroup{ + ManifestFile: manifest, + PackageName: packageName, + Findings: []snykVulnerability{finding}, + }) + } + + sort.SliceStable(groups, func(i, j int) bool { + return snykGroupPriority(groups[i]) < snykGroupPriority(groups[j]) + }) + return groups +} + +func snykGroupPriority(group snykRemediationGroup) int { + switch { + case strings.HasPrefix(group.PackageName, "org.springframework.boot:"): + return 0 + case strings.HasPrefix(group.PackageName, "org.springframework:"): + return 2 + default: + return 1 + } +} + +func snykTargetFile(result snykScanResult) string { + for _, candidate := range []string{result.TargetFile, result.DisplayTargetFile} { + if candidate != "" { + return filepath.ToSlash(candidate) + } + } + switch result.PackageManager { + case "maven": + return "pom.xml" + case "npm", "yarn": + return "package.json" + default: + return "" + } +} + +func snykManifestPath(finding snykVulnerability) string { + if finding.ManifestFile != "" { + return finding.ManifestFile + } + target := finding.TargetFile + if target == "" { + switch finding.PackageManager { + case "maven": + target = "pom.xml" + case "npm", "yarn": + target = "package.json" + } + } + if finding.ProjectPath == "" || finding.ProjectPath == "." { + return target + } + return filepath.ToSlash(filepath.Join(finding.ProjectPath, target)) +} + +func snykManifestFile(root string, result snykScanResult) string { + target := snykTargetFile(result) + if target == "" { + return "" + } + + candidate := filepath.FromSlash(target) + if !filepath.IsAbs(candidate) && result.Path != "" && result.Path != "." { + candidate = filepath.Join(filepath.FromSlash(result.Path), candidate) + } + + rel, err := filepath.Rel(root, candidate) + if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return filepath.ToSlash(rel) + } + + return filepath.ToSlash(filepath.Clean(candidate)) +} + +func snykManifestContext(root string, finding snykVulnerability) (string, error) { + manifest := snykManifestPath(finding) + path, err := safeJoin(root, manifest) + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read manifest %s: %w", manifest, err) + } + return fmt.Sprintf("--- %s ---\n%s", manifest, string(data)), nil +} + +func buildSnykRemediationPrompt(projectInfo *detectors.ProjectInfo, finding snykVulnerability, contextText string) string { + return fmt.Sprintf(`You are an expert dependency remediation agent. + +Project: +- Language: %s +- Framework: %s +- Detected package file: %s + +Snyk vulnerability: +- ID: %s +- Title: %s +- Severity: %s +- Package: %s +- Current version: %s +- Package manager: %s +- Project: %s +- Manifest: %s +- Is upgradable: %t +- Is patchable: %t +- Nearest fixed version: %s +- Fixed in versions: %s +- Upgrade path: %s +- Dependency paths: %s +- Recommendation: %s +- Description: %s + +Manifest / lockfile context: +%s + +Strict instructions: +- Apply exactly the Snyk remediation recommendation. +- Fix only this vulnerability. +- Prefer manifest and lockfile changes when appropriate. +- Do not modify unrelated business logic. +- Keep Spring Boot / React compatibility. +- Return only a unified diff patch that can be applied with git apply. +- Do not include markdown fences, explanations, prose, or comments outside the patch. +- The patch must include file headers (diff --git or ---/+++). +`, projectInfo.Language, projectInfo.Framework, projectInfo.PackageFile, finding.ID, finding.Title, strings.ToUpper(finding.Severity), finding.PackageName, finding.Version, finding.PackageManager, finding.ProjectName, snykManifestPath(finding), finding.IsUpgradable, finding.IsPatchable, finding.NearestFixedInVersion, strings.Join(finding.FixedIn, ", "), formatUpgradePath(finding.UpgradePath), strings.Join(finding.From, " > "), finding.Recommendation, finding.Description, contextText) +} + +func buildSnykGroupRemediationPrompt(projectInfo *detectors.ProjectInfo, group snykRemediationGroup, contextText string) string { + finding := group.Findings[0] + return fmt.Sprintf(`You are an expert dependency remediation agent. + +Project: +- Language: %s +- Framework: %s +- Detected package file: %s + +Snyk vulnerable dependency group: +- Package: %s +- Current version: %s +- Package manager: %s +- Project: %s +- Manifest: %s +- Findings in this group: %d + +Snyk findings to fix together: +%s + +Manifest / lockfile context: +%s + +Strict instructions: +- Apply the Snyk remediation recommendation for this dependency. +- Fix all listed HIGH/CRITICAL vulnerabilities for this package in one patch. +- Prefer the lowest safe fixed version that resolves all listed findings. +- Prefer manifest and lockfile changes when appropriate. +- Do not modify unrelated business logic. +- Keep Spring Boot / React compatibility. +- Return only a unified diff patch that can be applied with git apply. +- Do not include markdown fences, explanations, prose, or comments outside the patch. +- The patch must include file headers (diff --git or ---/+++). +`, projectInfo.Language, projectInfo.Framework, projectInfo.PackageFile, group.PackageName, finding.Version, finding.PackageManager, finding.ProjectName, group.ManifestFile, len(group.Findings), snykGroupFindingsText(group), contextText) +} + +func snykGroupFindingsText(group snykRemediationGroup) string { + var b strings.Builder + for _, finding := range group.Findings { + fmt.Fprintf(&b, "- ID: %s\n", finding.ID) + fmt.Fprintf(&b, " Title: %s\n", finding.Title) + fmt.Fprintf(&b, " Severity: %s\n", strings.ToUpper(finding.Severity)) + fmt.Fprintf(&b, " Nearest fixed version: %s\n", finding.NearestFixedInVersion) + fmt.Fprintf(&b, " Fixed in versions: %s\n", strings.Join(finding.FixedIn, ", ")) + fmt.Fprintf(&b, " Upgrade path: %s\n", formatUpgradePath(finding.UpgradePath)) + fmt.Fprintf(&b, " Recommendation: %s\n", finding.Recommendation) + } + return b.String() +} + +func formatUpgradePath(path []interface{}) string { + if len(path) == 0 { + return "" + } + parts := make([]string, 0, len(path)) + for _, item := range path { + switch v := item.(type) { + case string: + parts = append(parts, v) + case bool: + parts = append(parts, fmt.Sprintf("%t", v)) + default: + parts = append(parts, fmt.Sprintf("%v", v)) + } + } + return strings.Join(parts, " > ") +} + +func snykFindingStillPresent(original snykVulnerability, findings []snykVulnerability) bool { + for _, finding := range findings { + if finding.ID == original.ID && finding.PackageName == original.PackageName { + return true + } + } + return false +} + +func snykGroupStillPresent(group snykRemediationGroup, findings []snykVulnerability) bool { + return snykGroupPresentCount(group, findings) > 0 +} + +func snykGroupPresentCount(group snykRemediationGroup, findings []snykVulnerability) int { + count := 0 + for _, original := range group.Findings { + if snykFindingStillPresent(original, findings) { + count++ + } + } + return count +} + +func applySnykMavenFallback(root string, group snykRemediationGroup) ([]fileBackup, error) { + if !strings.HasSuffix(group.ManifestFile, "pom.xml") { + return nil, fmt.Errorf("no deterministic fallback for manifest %s", group.ManifestFile) + } + groupID, artifactID, ok := strings.Cut(group.PackageName, ":") + if !ok || groupID == "" || artifactID == "" { + return nil, fmt.Errorf("package %s is not a Maven coordinate", group.PackageName) + } + version := snykGroupFixedVersion(group) + if version == "" { + return nil, fmt.Errorf("no fixed version found for %s", group.PackageName) + } + + path, err := safeJoin(root, group.ManifestFile) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + updated, changed := updateDirectMavenDependencyVersion(string(data), groupID, artifactID, version) + if !changed && strings.HasPrefix(group.PackageName, "org.springframework.boot:") { + updated, changed = updateSpringBootParentVersion(string(data), version) + } + if !changed { + return nil, fmt.Errorf("dependency %s was not found as a direct dependency in %s", group.PackageName, group.ManifestFile) + } + + backups := []fileBackup{{Path: path, Data: data, Exists: true}} + if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { + return nil, err + } + return backups, nil +} + +func snykGroupFixedVersion(group snykRemediationGroup) string { + var nearest []string + var fixed []string + var recommended []string + for _, finding := range group.Findings { + if finding.NearestFixedInVersion != "" { + nearest = append(nearest, finding.NearestFixedInVersion) + } + fixed = append(fixed, finding.FixedIn...) + recommended = append(recommended, versionsFromText(finding.Recommendation)...) + } + if version := highestVersion(nearest); version != "" { + return version + } + if version := highestVersion(fixed); version != "" { + return version + } + return highestVersion(recommended) +} + +func versionsFromText(text string) []string { + re := regexp.MustCompile(`\b\d+(?:\.\d+){1,4}(?:[-.][A-Za-z0-9]+)?\b`) + return re.FindAllString(text, -1) +} + +func highestVersion(versions []string) string { + best := "" + for _, version := range versions { + version = strings.TrimSpace(version) + if version == "" { + continue + } + if best == "" || compareMavenVersion(version, best) > 0 { + best = version + } + } + return best +} + +func compareMavenVersion(a, b string) int { + aParts := versionParts(a) + bParts := versionParts(b) + max := len(aParts) + if len(bParts) > max { + max = len(bParts) + } + for i := 0; i < max; i++ { + ai, bi := 0, 0 + if i < len(aParts) { + ai = aParts[i] + } + if i < len(bParts) { + bi = bParts[i] + } + if ai > bi { + return 1 + } + if ai < bi { + return -1 + } + } + return 0 +} + +func versionParts(version string) []int { + re := regexp.MustCompile(`\d+`) + matches := re.FindAllString(version, -1) + parts := make([]int, 0, len(matches)) + for _, match := range matches { + var value int + for _, ch := range match { + value = value*10 + int(ch-'0') + } + parts = append(parts, value) + } + return parts +} + +func updateDirectMavenDependencyVersion(pom, groupID, artifactID, version string) (string, bool) { + dependencyRe := regexp.MustCompile(`(?s).*?`) + locs := dependencyRe.FindAllStringIndex(pom, -1) + for _, loc := range locs { + block := pom[loc[0]:loc[1]] + if !mavenTagEquals(block, "groupId", groupID) || !mavenTagEquals(block, "artifactId", artifactID) { + continue + } + versionRe := regexp.MustCompile(`(?s)(\s*)[^<]+(\s*)`) + if !versionRe.MatchString(block) { + return "", false + } + newBlock := versionRe.ReplaceAllString(block, "${1}"+version+"${2}") + return pom[:loc[0]] + newBlock + pom[loc[1]:], newBlock != block + } + return "", false +} + +func updateSpringBootParentVersion(pom, version string) (string, bool) { + parentRe := regexp.MustCompile(`(?s).*?`) + loc := parentRe.FindStringIndex(pom) + if loc == nil { + return "", false + } + block := pom[loc[0]:loc[1]] + if !mavenTagEquals(block, "groupId", "org.springframework.boot") || + !mavenTagEquals(block, "artifactId", "spring-boot-starter-parent") { + return "", false + } + versionRe := regexp.MustCompile(`(?s)(\s*)[^<]+(\s*)`) + if !versionRe.MatchString(block) { + return "", false + } + newBlock := versionRe.ReplaceAllString(block, "${1}"+version+"${2}") + return pom[:loc[0]] + newBlock + pom[loc[1]:], newBlock != block +} + +func mavenTagEquals(block, tag, value string) bool { + re := regexp.MustCompile(`(?s)<` + regexp.QuoteMeta(tag) + `>\s*` + regexp.QuoteMeta(value) + `\s*`) + return re.MatchString(block) +} + +func runValidationForFiles(root string, projectInfo *detectors.ProjectInfo, files []string) error { + commands := validationCommandsForFiles(root, projectInfo, files) + if len(commands) == 0 { + return runProjectTests(root, projectInfo) + } + + for _, command := range commands { + cmd := exec.Command(command.Name, command.Args...) + cmd.Dir = command.Dir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s failed: %w\n%s", command.Name, strings.Join(command.Args, " "), err, commandOutputTail(output, 40)) + } + } + return nil +} + +type validationCommand struct { + Dir string + Name string + Args []string +} + +func validationCommandsForFiles(root string, projectInfo *detectors.ProjectInfo, files []string) []validationCommand { + seen := make(map[string]bool) + var commands []validationCommand + + for _, file := range files { + clean := filepath.ToSlash(filepath.Clean(file)) + switch { + case strings.HasSuffix(clean, "pom.xml"): + dir := filepath.Dir(filepath.Join(root, clean)) + key := "maven:" + dir + if !seen[key] { + seen[key] = true + name, args := mavenCommand(dir) + commands = append(commands, validationCommand{Dir: dir, Name: name, Args: args}) + } + case strings.HasSuffix(clean, "build.gradle") || strings.HasSuffix(clean, "build.gradle.kts"): + dir := filepath.Dir(filepath.Join(root, clean)) + key := "gradle:" + dir + if !seen[key] { + seen[key] = true + name, args := gradleCommand(dir) + commands = append(commands, validationCommand{Dir: dir, Name: name, Args: args}) + } + case strings.HasSuffix(clean, "package.json"): + dir := filepath.Dir(filepath.Join(root, clean)) + key := "npm:" + dir + if !seen[key] { + seen[key] = true + commands = append(commands, validationCommand{Dir: dir, Name: "npm", Args: []string{"test"}}) + } + } + } + + if len(commands) == 0 && projectInfo != nil { + name, args, err := testCommand(root, projectInfo) + if err == nil { + commands = append(commands, validationCommand{Dir: root, Name: name, Args: args}) + } + } + + sort.Slice(commands, func(i, j int) bool { + if commands[i].Dir != commands[j].Dir { + return commands[i].Dir < commands[j].Dir + } + return commands[i].Name < commands[j].Name + }) + return commands +} + +func mavenCommand(dir string) (string, []string) { + if runtime.GOOS == "windows" && fileExists(filepath.Join(dir, "mvnw.cmd")) { + return filepath.Join(dir, "mvnw.cmd"), []string{"test"} + } + if fileExists(filepath.Join(dir, "mvnw")) { + return filepath.Join(dir, "mvnw"), []string{"test"} + } + return "mvn", []string{"test"} +} + +func gradleCommand(dir string) (string, []string) { + if runtime.GOOS == "windows" && fileExists(filepath.Join(dir, "gradlew.bat")) { + return filepath.Join(dir, "gradlew.bat"), []string{"test"} + } + if fileExists(filepath.Join(dir, "gradlew")) { + return filepath.Join(dir, "gradlew"), []string{"test"} + } + return "gradle", []string{"test"} +} diff --git a/cli/cmd/remediate_snyk_test.go b/cli/cmd/remediate_snyk_test.go new file mode 100644 index 0000000..a55505b --- /dev/null +++ b/cli/cmd/remediate_snyk_test.go @@ -0,0 +1,215 @@ +package cmd + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestParseSnykResultsSingleProject(t *testing.T) { + data := []byte(`{ + "projectName": "spring-app", + "packageManager": "maven", + "displayTargetFile": "pom.xml", + "vulnerabilities": [ + { + "id": "SNYK-JAVA-ORGSPRINGFRAMEWORK-123", + "title": "Example vuln", + "severity": "high", + "packageName": "org.springframework:spring-web", + "version": "5.3.0", + "isUpgradable": true, + "nearestFixedInVersion": "5.3.39" + } + ] + }`) + + results, err := parseSnykResults(data) + if err != nil { + t.Fatalf("expected snyk result to parse: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected one result, got %d", len(results)) + } + if len(results[0].Vulnerabilities) != 1 { + t.Fatalf("expected one vulnerability, got %d", len(results[0].Vulnerabilities)) + } +} + +func TestParseSnykResultsAllProjectsArray(t *testing.T) { + data := []byte(`[ + { + "projectName": "backend", + "packageManager": "maven", + "displayTargetFile": "backend/pom.xml", + "vulnerabilities": [] + }, + { + "projectName": "frontend", + "packageManager": "npm", + "displayTargetFile": "frontend/package.json", + "vulnerabilities": [ + { "id": "SNYK-JS-LODASH-567746", "severity": "critical", "packageName": "lodash" } + ] + } + ]`) + + results, err := parseSnykResults(data) + if err != nil { + t.Fatalf("expected snyk all-projects result to parse: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected two results, got %d", len(results)) + } +} + +func TestSnykHighCriticalFiltersMedium(t *testing.T) { + findings := []snykVulnerability{ + {ID: "low", Severity: "low"}, + {ID: "medium", Severity: "medium"}, + {ID: "high", Severity: "high"}, + {ID: "critical", Severity: "critical"}, + } + + targets := snykHighCritical(findings) + if len(targets) != 2 { + t.Fatalf("expected two high/critical targets, got %d", len(targets)) + } +} + +func TestSnykManifestPathUsesProjectPathAndTarget(t *testing.T) { + finding := snykVulnerability{ + ProjectPath: "backend", + TargetFile: "pom.xml", + } + + if got := snykManifestPath(finding); got != "backend/pom.xml" { + t.Fatalf("expected backend/pom.xml, got %s", got) + } +} + +func TestSnykManifestFileNormalizesAbsoluteProjectPath(t *testing.T) { + root := t.TempDir() + result := snykScanResult{ + Path: root, + DisplayTargetFile: "pom.xml", + PackageManager: "maven", + } + + if got := snykManifestFile(root, result); got != "pom.xml" { + t.Fatalf("expected pom.xml, got %s", got) + } +} + +func TestSnykManifestFileNormalizesAbsoluteTargetFile(t *testing.T) { + root := t.TempDir() + result := snykScanResult{ + DisplayTargetFile: filepath.Join(root, "pom.xml"), + PackageManager: "maven", + } + + if got := snykManifestFile(root, result); got != "pom.xml" { + t.Fatalf("expected pom.xml, got %s", got) + } +} + +func TestGroupSnykFindingsGroupsByManifestAndPackage(t *testing.T) { + findings := []snykVulnerability{ + {ID: "one", PackageName: "org.postgresql:postgresql", TargetFile: "pom.xml"}, + {ID: "two", PackageName: "org.postgresql:postgresql", TargetFile: "pom.xml"}, + {ID: "three", PackageName: "ch.qos.logback:logback-core", TargetFile: "pom.xml"}, + } + + groups := groupSnykFindings(findings) + if len(groups) != 2 { + t.Fatalf("expected two remediation groups, got %d", len(groups)) + } + if len(groups[0].Findings) != 2 { + t.Fatalf("expected first group to contain two findings, got %d", len(groups[0].Findings)) + } + if groups[0].PackageName != "org.postgresql:postgresql" { + t.Fatalf("expected postgresql group first, got %s", groups[0].PackageName) + } +} + +func TestGroupSnykFindingsPrioritizesSpringBootParentCandidates(t *testing.T) { + findings := []snykVulnerability{ + {ID: "spring-core", PackageName: "org.springframework:spring-core", TargetFile: "pom.xml"}, + {ID: "boot", PackageName: "org.springframework.boot:spring-boot", TargetFile: "pom.xml"}, + {ID: "postgres", PackageName: "org.postgresql:postgresql", TargetFile: "pom.xml"}, + } + + groups := groupSnykFindings(findings) + if groups[0].PackageName != "org.springframework.boot:spring-boot" { + t.Fatalf("expected spring boot group first, got %s", groups[0].PackageName) + } +} + +func TestSnykGroupFixedVersionPrefersNearestFixedVersion(t *testing.T) { + group := snykRemediationGroup{ + PackageName: "org.postgresql:postgresql", + ManifestFile: "pom.xml", + Findings: []snykVulnerability{ + {NearestFixedInVersion: "42.3.9"}, + {FixedIn: []string{"42.7.2", "42.7.7"}}, + }, + } + + if got := snykGroupFixedVersion(group); got != "42.3.9" { + t.Fatalf("expected 42.3.9, got %s", got) + } +} + +func TestSnykGroupFixedVersionUsesHighestNearestFixedVersion(t *testing.T) { + group := snykRemediationGroup{ + PackageName: "ch.qos.logback:logback-core", + ManifestFile: "pom.xml", + Findings: []snykVulnerability{ + {NearestFixedInVersion: "1.4.12"}, + {NearestFixedInVersion: "1.5.13"}, + }, + } + + if got := snykGroupFixedVersion(group); got != "1.5.13" { + t.Fatalf("expected 1.5.13, got %s", got) + } +} + +func TestUpdateSpringBootParentVersion(t *testing.T) { + pom := ` + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + +` + + updated, changed := updateSpringBootParentVersion(pom, "3.3.13") + if !changed { + t.Fatal("expected spring boot parent version to change") + } + if !strings.Contains(updated, "3.3.13") { + t.Fatalf("expected updated parent version in pom, got %s", updated) + } +} + +func TestUpdateDirectMavenDependencyVersion(t *testing.T) { + pom := ` + + + org.postgresql + postgresql + 42.3.3 + + +` + + updated, changed := updateDirectMavenDependencyVersion(pom, "org.postgresql", "postgresql", "42.7.7") + if !changed { + t.Fatal("expected dependency version to change") + } + if !strings.Contains(updated, "42.7.7") { + t.Fatalf("expected updated version in pom, got %s", updated) + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index d4e2127..4e0e6fc 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -9,8 +9,10 @@ import ( ) var ( - // version is set at build time via -ldflags, default for dev builds. - version = "0.6.0" + // Build metadata is set at build time via -ldflags. + version = "development" + commit = "none" + date = "unknown" rootCmd = &cobra.Command{ Use: "devsecops", @@ -37,7 +39,21 @@ func init() { Use: "version", Short: "Print the version number", Run: func(cmd *cobra.Command, args []string) { - fmt.Fprintf(os.Stdout, "DevSecOps Kit version %s\n", version) + printVersion() }, }) } + +func printVersion() { + fmt.Fprintln(os.Stdout, "DevSecOps Kit") + + if commit == "none" && date == "unknown" { + fmt.Fprintf(os.Stdout, "Version: %s\n", version) + return + } + + fmt.Fprintln(os.Stdout) + fmt.Fprintf(os.Stdout, "Version : %s\n", version) + fmt.Fprintf(os.Stdout, "Commit : %s\n", commit) + fmt.Fprintf(os.Stdout, "Built : %s\n", date) +} diff --git a/cli/reporters/html.go b/cli/reporters/html.go index b1488d7..5325fdd 100644 --- a/cli/reporters/html.go +++ b/cli/reporters/html.go @@ -94,6 +94,11 @@ func (hr *HTMLReporter) generateHTML() string { color: #721c24; } + .status-warn { + background: #fff3cd; + color: #856404; + } + .summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); @@ -375,24 +380,32 @@ func (hr *HTMLReporter) generateHTML() string { // getStatusIcon returns the emoji for the status func (hr *HTMLReporter) getStatusIcon() string { - if hr.report.Status == "PASS" { + switch hr.report.Status { + case "PASS": return "βœ…" + case "WARN": + return "⚠️" + default: + return "❌" } - return "❌" } // getStatusClass returns the CSS class for the status func (hr *HTMLReporter) getStatusClass() string { - if hr.report.Status == "PASS" { + switch hr.report.Status { + case "PASS": return "pass" + case "WARN": + return "warn" + default: + return "fail" } - return "fail" } // getBlockingCountHTML returns HTML for blocking count if applicable func (hr *HTMLReporter) getBlockingCountHTML() string { if hr.report.BlockingCount > 0 { - return fmt.Sprintf(`

⚠️ %d issue(s) exceed configured thresholds

`, hr.report.BlockingCount) + return fmt.Sprintf(`

⚠️ %d blocking finding(s) detected

`, hr.report.BlockingCount) } return "" } diff --git a/cli/reporters/terminal.go b/cli/reporters/terminal.go index 986f15a..90cdf40 100644 --- a/cli/reporters/terminal.go +++ b/cli/reporters/terminal.go @@ -52,16 +52,13 @@ func (tr *TerminalReporter) printSummary() { fmt.Println(statusColor(fmt.Sprintf("%s Status: %s", statusIcon, tr.report.Status))) if tr.report.BlockingCount > 0 { - fmt.Println(colorRed(fmt.Sprintf(" ⚠️ %d issue(s) exceed thresholds", tr.report.BlockingCount))) + fmt.Println(colorRed(fmt.Sprintf(" ⚠️ %d blocking finding(s) detected", tr.report.BlockingCount))) } fmt.Println() fmt.Println(colorCyan("Summary by Tool:")) - // Sort tools for consistent output - tools := []string{"gitleaks", "semgrep", "trivy"} - - for _, tool := range tools { + for _, tool := range sortedResultTools(tr.report.Results) { if result, ok := tr.report.Results[tool]; ok { tr.printToolSummary(tool, result) } @@ -121,10 +118,7 @@ func (tr *TerminalReporter) printFindings() { findingsByTool[finding.Tool] = append(findingsByTool[finding.Tool], finding) } - // Sort tools - tools := []string{"gitleaks", "semgrep", "trivy"} - - for _, tool := range tools { + for _, tool := range sortedFindingTools(findingsByTool) { findings, ok := findingsByTool[tool] if !ok || len(findings) == 0 { continue @@ -198,16 +192,41 @@ func (tr *TerminalReporter) printFinding(finding scanners.Finding) { func (tr *TerminalReporter) printFooter() { fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) - if tr.report.BlockingCount > 0 { - fmt.Println(colorRed("❌ Scan FAILED - Review findings above and remediate before proceeding")) - } else { - fmt.Println(colorGreen("βœ… Scan PASSED - No blocking issues detected")) + switch tr.report.Status { + case "FAIL": + fmt.Println(colorRed("❌ Scan FAILED")) + fmt.Println(colorRed("Blocking security findings detected.")) + case "WARN": + fmt.Println(colorYellow("⚠ Scan completed with warnings")) + fmt.Println(colorYellow(fmt.Sprintf("%d finding(s) detected.", len(tr.report.AllFindings)))) + case "PASS": + fmt.Println(colorGreen("βœ… Scan PASSED - No findings detected")) + default: + fmt.Println(colorYellow("⚠ Scan completed with unknown status")) } fmt.Println(colorCyan("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) fmt.Println() } +func sortedResultTools(results map[string]*scanners.ScanResult) []string { + tools := make([]string, 0, len(results)) + for tool := range results { + tools = append(tools, tool) + } + sort.Strings(tools) + return tools +} + +func sortedFindingTools(findings map[string][]scanners.Finding) []string { + tools := make([]string, 0, len(findings)) + for tool := range findings { + tools = append(tools, tool) + } + sort.Strings(tools) + return tools +} + // Color functions for terminal output func colorRed(s string) string { return fmt.Sprintf("\033[91m%s\033[0m", s) diff --git a/cli/scanners/gitleaks.go b/cli/scanners/gitleaks.go index 960f155..970e3d2 100644 --- a/cli/scanners/gitleaks.go +++ b/cli/scanners/gitleaks.go @@ -85,6 +85,7 @@ func (o *Orchestrator) runGitleaks() (*ScanResult, error) { File: leak.File, Line: leak.StartLine, Severity: "CRITICAL", + Blocking: true, Message: fmt.Sprintf("Secret detected: %s", leak.RuleID), RuleID: leak.RuleID, Tool: "gitleaks", diff --git a/cli/scanners/orchestrator.go b/cli/scanners/orchestrator.go index e126e80..c44f16b 100644 --- a/cli/scanners/orchestrator.go +++ b/cli/scanners/orchestrator.go @@ -4,6 +4,8 @@ import ( "fmt" "sync" "time" + + scanreport "github.com/edgarpsda/devsecops-kit/internal/report" ) // Orchestrator coordinates running multiple security scanners @@ -123,81 +125,29 @@ func (o *Orchestrator) Run() (*ScanReport, error) { return nil, fmt.Errorf("all scanners failed: %v", errors) } - // Calculate blocking count based on thresholds - o.calculateBlockingCount(report) - - // Set overall status - if report.BlockingCount > 0 { - report.Status = "FAIL" - } else { - report.Status = "PASS" - } + o.evaluateStatus(report, len(errors) > 0) return report, nil } -// calculateBlockingCount determines how many findings exceed thresholds -func (o *Orchestrator) calculateBlockingCount(report *ScanReport) { - report.BlockingCount = 0 - - // Check Gitleaks threshold - if gitleaks, ok := report.Results["gitleaks"]; ok { - if threshold, exists := o.options.FailOnThresholds["gitleaks"]; exists && threshold >= 0 { - if gitleaks.Summary.Total > threshold { - report.BlockingCount += gitleaks.Summary.Total - threshold - } - } - } - - // Check Semgrep threshold - if semgrep, ok := report.Results["semgrep"]; ok { - if threshold, exists := o.options.FailOnThresholds["semgrep"]; exists && threshold >= 0 { - if semgrep.Summary.Total > threshold { - report.BlockingCount += semgrep.Summary.Total - threshold - } - } - } - - // Check Trivy thresholds (by severity) - if trivy, ok := report.Results["trivy"]; ok { - severities := map[string]string{ - "critical": "trivy_critical", - "high": "trivy_high", - "medium": "trivy_medium", - "low": "trivy_low", - } - - counts := map[string]int{ - "critical": trivy.Summary.Critical, - "high": trivy.Summary.High, - "medium": trivy.Summary.Medium, - "low": trivy.Summary.Low, - } - - for severity, configKey := range severities { - if threshold, exists := o.options.FailOnThresholds[configKey]; exists && threshold >= 0 { - if count, ok := counts[severity]; ok && count > threshold { - report.BlockingCount += count - threshold - } - } - } - } - - // Check License violations threshold - if licenses, ok := report.Results["licenses"]; ok { - if threshold, exists := o.options.FailOnThresholds["license_violations"]; exists && threshold >= 0 { - if licenses.Summary.Total > threshold { - report.BlockingCount += licenses.Summary.Total - threshold - } - } - } - - // Check Checkov threshold - if checkov, ok := report.Results["checkov"]; ok { - if threshold, exists := o.options.FailOnThresholds["checkov"]; exists && threshold >= 0 { - if checkov.Summary.Total > threshold { - report.BlockingCount += checkov.Summary.Total - threshold - } - } +func (o *Orchestrator) evaluateStatus(report *ScanReport, hasScannerErrors bool) { + findings := make([]scanreport.Finding, 0, len(report.AllFindings)) + for _, finding := range report.AllFindings { + findings = append(findings, scanreport.Finding{ + Tool: finding.Tool, + Severity: finding.Severity, + Blocking: finding.Blocking, + Rule: finding.RuleID, + File: finding.File, + Line: finding.Line, + }) + } + + evaluation := scanreport.Evaluate(findings) + report.Status = evaluation.Status + report.BlockingCount = evaluation.BlockingCount + + if hasScannerErrors && report.Status == scanreport.StatusPass { + report.Status = scanreport.StatusFail } } diff --git a/cli/scanners/semgrep.go b/cli/scanners/semgrep.go index aada44d..22d5441 100644 --- a/cli/scanners/semgrep.go +++ b/cli/scanners/semgrep.go @@ -1,27 +1,31 @@ package scanners import ( + "bytes" "encoding/json" "fmt" "os" "os/exec" "strings" + + "github.com/edgarpsda/devsecops-kit/internal/tools" ) // SemgrepOutput represents the JSON output from Semgrep type SemgrepOutput struct { Results []struct { - Path string `json:"path"` - StartLine int `json:"start_line"` - EndLine int `json:"end_line"` - Message string `json:"message"` - Severity string `json:"severity"` - RuleID string `json:"check_id"` + Path string `json:"path"` + Start SemgrepPosition `json:"start"` + End SemgrepPosition `json:"end"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Message string `json:"message"` + Severity string `json:"severity"` + RuleID string `json:"check_id"` Extra struct { - Severity string `json:"severity"` - Metadata struct { - CWE []string `json:"cwe"` - } `json:"metadata"` + Message string `json:"message"` + Severity string `json:"severity"` + Metadata map[string]interface{} `json:"metadata"` } `json:"extra"` } `json:"results"` Errors []struct { @@ -30,6 +34,13 @@ type SemgrepOutput struct { } `json:"errors"` } +// SemgrepPosition represents a Semgrep JSON source position. +type SemgrepPosition struct { + Line int `json:"line"` + Column int `json:"col"` + Offset int `json:"offset,omitempty"` +} + // runSemgrep executes a Semgrep scan func (o *Orchestrator) runSemgrep() (*ScanResult, error) { result := &ScanResult{ @@ -46,7 +57,7 @@ func (o *Orchestrator) runSemgrep() (*ScanResult, error) { } // Build Semgrep command - cmd := exec.Command("semgrep", "--config", "p/ci", "--json") + cmd := tools.Command("semgrep", "scan", "--config", "auto", "--json", "--quiet") // Add exclude paths for _, path := range o.options.ExcludePaths { @@ -56,59 +67,38 @@ func (o *Orchestrator) runSemgrep() (*ScanResult, error) { // Set working directory cmd.Dir = o.projectDir - // Capture output - output, err := cmd.CombinedOutput() - if err != nil { - // Semgrep exits with code 1 if findings are detected, which is not a real error - // Only fail if the output isn't JSON or contains parsing errors - if !strings.Contains(string(output), "\"results\"") { - result.Status = "error" - result.Error = fmt.Errorf("semgrep execution failed: %w", err) - return result, result.Error + // Capture stdout and stderr separately so only stdout is parsed as JSON. + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Parse JSON output + err := cmd.Run() + jsonOutput, parseErr := semgrepJSONPayload(stdout.Bytes()) + if parseErr != nil { + result.Status = "error" + if err != nil { + result.Error = fmt.Errorf("semgrep execution failed: %w\n\nunable to parse Semgrep JSON output: %v\n\nstdout:\n%s\n\nstderr:\n%s", err, parseErr, stdout.String(), stderr.String()) + } else { + result.Error = fmt.Errorf("unable to parse Semgrep JSON output: %w\n\nstdout:\n%s\n\nstderr:\n%s", parseErr, stdout.String(), stderr.String()) } + return result, result.Error } - // Parse JSON output var semgrepOut SemgrepOutput - if err := json.Unmarshal(output, &semgrepOut); err != nil { + if err := json.Unmarshal(jsonOutput, &semgrepOut); err != nil { result.Status = "error" - result.Error = fmt.Errorf("failed to parse semgrep output: %w", err) + result.Error = fmt.Errorf("unable to parse Semgrep JSON output: %w\n\nstdout:\n%s\n\nstderr:\n%s", err, stdout.String(), stderr.String()) return result, result.Error } - // Convert to findings - for _, sr := range semgrepOut.Results { - severity := sr.Extra.Severity - if severity == "" { - severity = sr.Severity - } - - // Normalize severity - normSeverity := normalizeSeverity(severity) + result.Findings = semgrepFindings(semgrepOut) + result.Summary = summarizeFindings(result.Findings) - finding := Finding{ - File: sr.Path, - Line: sr.StartLine, - Severity: normSeverity, - Message: sr.Message, - RuleID: sr.RuleID, - Tool: "semgrep", - } - - result.Findings = append(result.Findings, finding) - - // Update summary counts - result.Summary.Total++ - switch normSeverity { - case "CRITICAL": - result.Summary.Critical++ - case "HIGH": - result.Summary.High++ - case "MEDIUM": - result.Summary.Medium++ - case "LOW": - result.Summary.Low++ - } + if o.options.Verbose { + fmt.Fprintf(os.Stderr, "Semgrep JSON results: %d\n", len(semgrepOut.Results)) + fmt.Fprintf(os.Stderr, "Semgrep findings converted: %d\n", len(result.Findings)) } if len(semgrepOut.Errors) > 0 { @@ -128,3 +118,87 @@ func (o *Orchestrator) runSemgrep() (*ScanResult, error) { return result, nil } + +func semgrepFindings(output SemgrepOutput) []Finding { + var findings []Finding + + for _, sr := range output.Results { + line := sr.Start.Line + if line == 0 { + line = sr.StartLine + } + + column := sr.Start.Column + message := sr.Extra.Message + if message == "" { + message = sr.Message + } + + severity := sr.Extra.Severity + if severity == "" { + severity = sr.Severity + } + + findings = append(findings, Finding{ + File: sr.Path, + Line: line, + Column: column, + Severity: normalizeSeverity(severity), + Blocking: semgrepBlocking(sr.Extra.Metadata), + Message: message, + RuleID: sr.RuleID, + Tool: "semgrep", + }) + } + + return findings +} + +func semgrepBlocking(metadata map[string]interface{}) bool { + for _, key := range []string{"blocking", "block", "devsecops_blocking"} { + value, ok := metadata[key] + if !ok { + continue + } + if boolValue(value) { + return true + } + } + return false +} + +func boolValue(value interface{}) bool { + switch v := value.(type) { + case bool: + return v + case string: + return strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") || v == "1" + default: + return false + } +} + +func semgrepJSONPayload(output []byte) ([]byte, error) { + if len(bytes.TrimSpace(output)) == 0 { + return nil, fmt.Errorf("stdout is empty") + } + + for i, b := range output { + if b != '{' { + continue + } + + decoder := json.NewDecoder(bytes.NewReader(output[i:])) + var raw json.RawMessage + if err := decoder.Decode(&raw); err != nil { + continue + } + if len(raw) == 0 || raw[0] != '{' { + continue + } + + return raw, nil + } + + return nil, fmt.Errorf("no valid JSON object found in stdout") +} diff --git a/cli/scanners/semgrep_test.go b/cli/scanners/semgrep_test.go new file mode 100644 index 0000000..bd03588 --- /dev/null +++ b/cli/scanners/semgrep_test.go @@ -0,0 +1,177 @@ +package scanners + +import ( + "encoding/json" + "testing" +) + +func TestSemgrepJSONPayloadOnlyJSON(t *testing.T) { + payload := []byte(`{"version":"1.155.0","results":[],"errors":[]}`) + + got, err := semgrepJSONPayload(payload) + if err != nil { + t.Fatalf("expected JSON payload: %v", err) + } + + assertSemgrepJSON(t, got) +} + +func TestSemgrepJSONPayloadBannerAndJSON(t *testing.T) { + payload := []byte("β”Œβ”€β”€β”€β”€ β—‹β—‹β—‹ ────┐\nβ”‚ Semgrep CLI β”‚\nβ””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜\n\nScanning...\n\n{\"version\":\"1.155.0\",\"results\":[],\"errors\":[]}") + + got, err := semgrepJSONPayload(payload) + if err != nil { + t.Fatalf("expected JSON payload after banner: %v", err) + } + + assertSemgrepJSON(t, got) +} + +func TestSemgrepJSONPayloadTextBeforeJSON(t *testing.T) { + payload := []byte("loading rules\nscanning project\n{\"version\":\"1.155.0\",\"results\":[],\"errors\":[]}") + + got, err := semgrepJSONPayload(payload) + if err != nil { + t.Fatalf("expected JSON payload after text: %v", err) + } + + assertSemgrepJSON(t, got) +} + +func TestSemgrepJSONPayloadSkipsInvalidObjectsBeforeJSON(t *testing.T) { + payload := []byte("banner {not json}\n{\"version\":\"1.155.0\",\"results\":[],\"errors\":[]}") + + got, err := semgrepJSONPayload(payload) + if err != nil { + t.Fatalf("expected parser to skip invalid object-like text: %v", err) + } + + assertSemgrepJSON(t, got) +} + +func TestSemgrepJSONPayloadInvalidOutput(t *testing.T) { + if _, err := semgrepJSONPayload([]byte("not json at all")); err == nil { + t.Fatal("expected invalid output to fail") + } +} + +func TestSemgrepJSONPayloadEmptyStdout(t *testing.T) { + if _, err := semgrepJSONPayload(nil); err == nil { + t.Fatal("expected empty stdout to fail") + } +} + +func TestSemgrepJSONPayloadValidStdoutWithStderrMessages(t *testing.T) { + stdout := []byte(`{"version":"1.155.0","results":[],"errors":[]}`) + stderr := []byte("informational warning on stderr") + + got, err := semgrepJSONPayload(stdout) + if err != nil { + t.Fatalf("expected stdout JSON to parse despite stderr: %v", err) + } + if len(stderr) == 0 { + t.Fatal("expected test fixture stderr message") + } + + assertSemgrepJSON(t, got) +} + +func TestSemgrepFindingsCurrentJSONShape(t *testing.T) { + payload := []byte(`{ + "version": "1.155.0", + "results": [ + { + "check_id": "python.flask.security.audit.app-run-param-config.avoid_app_run_with_bad_host", + "path": "app.py", + "start": { "line": 12, "col": 5, "offset": 120 }, + "end": { "line": 12, "col": 20, "offset": 135 }, + "extra": { + "message": "Running Flask app with an unsafe host is dangerous.", + "severity": "WARNING", + "metadata": { + "cwe": ["CWE-668"] + }, + "lines": "app.run(host='0.0.0.0')" + } + } + ], + "errors": [] + }`) + + var output SemgrepOutput + if err := json.Unmarshal(payload, &output); err != nil { + t.Fatalf("expected semgrep output to unmarshal: %v", err) + } + + findings := semgrepFindings(output) + if len(findings) != 1 { + t.Fatalf("expected one finding, got %d", len(findings)) + } + + finding := findings[0] + if finding.File != "app.py" { + t.Fatalf("expected file app.py, got %s", finding.File) + } + if finding.Line != 12 { + t.Fatalf("expected line 12, got %d", finding.Line) + } + if finding.Column != 5 { + t.Fatalf("expected column 5, got %d", finding.Column) + } + if finding.Severity != "MEDIUM" { + t.Fatalf("expected normalized WARNING severity to become MEDIUM, got %s", finding.Severity) + } + if finding.Message != "Running Flask app with an unsafe host is dangerous." { + t.Fatalf("unexpected message: %s", finding.Message) + } + if finding.RuleID != "python.flask.security.audit.app-run-param-config.avoid_app_run_with_bad_host" { + t.Fatalf("unexpected rule id: %s", finding.RuleID) + } +} + +func TestSemgrepFindingsBlockingMetadata(t *testing.T) { + payload := []byte(`{ + "version": "1.155.0", + "results": [ + { + "check_id": "custom.low.blocking", + "path": "app.py", + "start": { "line": 3, "col": 1 }, + "end": { "line": 3, "col": 10 }, + "extra": { + "message": "Explicitly blocking low finding.", + "severity": "LOW", + "metadata": { + "blocking": true + } + } + } + ], + "errors": [] + }`) + + var output SemgrepOutput + if err := json.Unmarshal(payload, &output); err != nil { + t.Fatalf("expected semgrep output to unmarshal: %v", err) + } + + findings := semgrepFindings(output) + if len(findings) != 1 { + t.Fatalf("expected one finding, got %d", len(findings)) + } + if !findings[0].Blocking { + t.Fatal("expected blocking metadata to mark finding as blocking") + } +} + +func assertSemgrepJSON(t *testing.T, payload []byte) { + t.Helper() + + var output SemgrepOutput + if err := json.Unmarshal(payload, &output); err != nil { + t.Fatalf("expected valid Semgrep JSON: %v", err) + } + if output.Results == nil { + t.Fatal("expected results field to be present") + } +} diff --git a/cli/scanners/types.go b/cli/scanners/types.go index 5c48bac..a411840 100644 --- a/cli/scanners/types.go +++ b/cli/scanners/types.go @@ -2,11 +2,11 @@ package scanners // ScanResult represents the output from a single scanner run type ScanResult struct { - Tool string `json:"tool"` // "semgrep", "gitleaks", "trivy" - Status string `json:"status"` // "success", "error", "no_findings" - Error error `json:"-"` - Findings []Finding `json:"findings"` - Summary FindingSummary `json:"summary"` + Tool string `json:"tool"` // "semgrep", "gitleaks", "trivy" + Status string `json:"status"` // "success", "error", "no_findings" + Error error `json:"-"` + Findings []Finding `json:"findings"` + Summary FindingSummary `json:"summary"` } // Finding represents a single security finding @@ -15,6 +15,7 @@ type Finding struct { Line int `json:"line"` Column int `json:"column,omitempty"` Severity string `json:"severity"` // "CRITICAL", "HIGH", "MEDIUM", "LOW", or rule ID + Blocking bool `json:"blocking,omitempty"` Message string `json:"message"` RuleID string `json:"rule_id,omitempty"` Tool string `json:"tool"` @@ -48,9 +49,9 @@ type ScanOptions struct { // ScanReport aggregates all scan results type ScanReport struct { - Timestamp string `json:"timestamp"` - Status string `json:"status"` // "PASS" or "FAIL" - BlockingCount int `json:"blocking_count"` - Results map[string]*ScanResult `json:"results"` // key: tool name - AllFindings []Finding `json:"all_findings"` + Timestamp string `json:"timestamp"` + Status string `json:"status"` // "PASS", "WARN", or "FAIL" + BlockingCount int `json:"blocking_count"` + Results map[string]*ScanResult `json:"results"` // key: tool name + AllFindings []Finding `json:"all_findings"` } diff --git a/cli/scanners/utils.go b/cli/scanners/utils.go index 672c49c..7cb32ae 100644 --- a/cli/scanners/utils.go +++ b/cli/scanners/utils.go @@ -1,24 +1,10 @@ package scanners -import ( - "strings" -) +import scanreport "github.com/edgarpsda/devsecops-kit/internal/report" // normalizeSeverity converts severity strings to standard format func normalizeSeverity(severity string) string { - s := strings.ToUpper(severity) - switch s { - case "CRITICAL", "CRITICAL,HIGH", "FATAL": - return "CRITICAL" - case "HIGH": - return "HIGH" - case "MEDIUM", "MODERATE": - return "MEDIUM" - case "LOW", "INFO": - return "LOW" - default: - return "MEDIUM" - } + return scanreport.NormalizeSeverity(severity) } // LoadConfigFromFile loads security configuration from security-config.yml diff --git a/internal/remediation/ai_patch.go b/internal/remediation/ai_patch.go new file mode 100644 index 0000000..14df340 --- /dev/null +++ b/internal/remediation/ai_patch.go @@ -0,0 +1,325 @@ +package remediation + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// AIPatchConfig holds AI patch provider configuration. +type AIPatchConfig struct { + Provider string // "ollama", "openai", "anthropic" + Model string + Endpoint string // for ollama; defaults to http://localhost:11434 + APIKey string // for openai/anthropic +} + +// AIPatchProvider generates structured patches using an AI backend. +type AIPatchProvider struct { + cfg AIPatchConfig + http *http.Client +} + +// NewAIPatchProvider creates an AI patch provider with the given config. +func NewAIPatchProvider(cfg AIPatchConfig) *AIPatchProvider { + if cfg.Provider == "" { + cfg.Provider = "ollama" + } + + endpoint := cfg.Endpoint + if endpoint == "" { + endpoint = "http://localhost:11434" + } + cfg.Endpoint = endpoint + + model := cfg.Model + if model == "" { + switch cfg.Provider { + case "openai": + model = "gpt-4o-mini" + case "anthropic": + model = "claude-haiku-4-5-20251001" + default: + model = "llama3" + } + } + cfg.Model = model + + return &AIPatchProvider{ + cfg: cfg, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Name returns the provider name. +func (p *AIPatchProvider) Name() string { + return "ai-patch" +} + +// Provider returns the configured AI backend. +func (p *AIPatchProvider) Provider() AIProvider { + return AIProvider(p.cfg.Provider) +} + +// Supports reports whether the request has enough context to generate a patch. +func (p *AIPatchProvider) Supports(request PatchRequest) bool { + return request.Finding.File != "" && request.Recommendation.Title != "" +} + +// GeneratePatch generates a structured patch proposal without applying it. +func (p *AIPatchProvider) GeneratePatch(ctx context.Context, request PatchRequest) (*PatchResponse, error) { + if !p.Supports(request) { + return nil, fmt.Errorf("patch request is missing finding file or recommendation title") + } + + prompt := buildPatchPrompt(request) + rawPatch, err := p.call(ctx, prompt) + if err != nil { + return nil, err + } + + patch, err := parsePatchResponse(rawPatch) + if err != nil { + return nil, err + } + if patch.File == "" { + patch.File = request.Finding.File + } + + return &PatchResponse{ + Provider: p.cfg.Provider, + Model: p.cfg.Model, + Patch: patch, + }, nil +} + +func (p *AIPatchProvider) call(ctx context.Context, prompt string) (string, error) { + switch p.cfg.Provider { + case "openai": + return p.callOpenAI(ctx, prompt) + case "anthropic": + return p.callAnthropic(ctx, prompt) + default: + return p.callOllama(ctx, prompt) + } +} + +func buildPatchPrompt(request PatchRequest) string { + var b strings.Builder + b.WriteString("You are a security remediation agent. Generate one structured patch proposal.\n") + b.WriteString("Do not describe steps outside the JSON response. Do not apply changes.\n") + b.WriteString("Return only JSON with this shape: {\"file\":\"path\",\"start_line\":1,\"end_line\":1,\"original\":\"old text\",\"replacement\":\"new text\",\"metadata\":{\"reason\":\"short reason\"}}.\n\n") + + b.WriteString("Finding:\n") + b.WriteString(fmt.Sprintf("- Tool: %s\n", request.Finding.Tool)) + b.WriteString(fmt.Sprintf("- Rule: %s\n", request.Finding.RuleID)) + b.WriteString(fmt.Sprintf("- Severity: %s\n", request.Finding.Severity)) + b.WriteString(fmt.Sprintf("- File: %s\n", request.Finding.File)) + b.WriteString(fmt.Sprintf("- Line: %d\n", request.Finding.Line)) + b.WriteString(fmt.Sprintf("- Message: %s\n\n", request.Finding.Message)) + + b.WriteString("Recommendation:\n") + b.WriteString(fmt.Sprintf("- Title: %s\n", request.Recommendation.Title)) + b.WriteString(fmt.Sprintf("- Description: %s\n", request.Recommendation.Description)) + if request.Recommendation.PackageImpact != nil { + b.WriteString(fmt.Sprintf("- Package: %s\n", request.Recommendation.PackageImpact.Name)) + b.WriteString(fmt.Sprintf("- Current version: %s\n", request.Recommendation.PackageImpact.CurrentVersion)) + b.WriteString(fmt.Sprintf("- Fixed version: %s\n", request.Recommendation.PackageImpact.FixedVersion)) + } + for _, step := range request.Recommendation.Steps { + b.WriteString(fmt.Sprintf("- Step: %s %s %s\n", step.Action, step.Target, step.Value)) + } + b.WriteString("\n") + + if len(request.Files) > 0 { + b.WriteString("File context:\n") + for _, file := range request.Files { + b.WriteString(fmt.Sprintf("--- %s ---\n%s\n", file.Path, file.Content)) + } + } + + return b.String() +} + +func parsePatchResponse(response string) (Patch, error) { + var patch Patch + jsonResponse := extractJSONObject([]byte(response)) + if len(jsonResponse) == 0 { + return patch, fmt.Errorf("ai patch response did not contain a JSON object") + } + if err := json.Unmarshal(jsonResponse, &patch); err != nil { + return patch, fmt.Errorf("failed to parse ai patch response: %w", err) + } + return patch, nil +} + +func extractJSONObject(data []byte) []byte { + start := bytes.IndexByte(data, '{') + if start == -1 { + return nil + } + end := bytes.LastIndexByte(data, '}') + if end == -1 || end < start { + return nil + } + return data[start : end+1] +} + +type aiPatchOllamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` +} + +type aiPatchOllamaResponse struct { + Response string `json:"response"` + Error string `json:"error,omitempty"` +} + +func (p *AIPatchProvider) callOllama(ctx context.Context, prompt string) (string, error) { + body, _ := json.Marshal(aiPatchOllamaRequest{ + Model: p.cfg.Model, + Prompt: prompt, + Stream: false, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", p.cfg.Endpoint+"/api/generate", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("ollama request creation failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := p.http.Do(req) + if err != nil { + return "", fmt.Errorf("ollama request failed: %w", err) + } + defer resp.Body.Close() + + var result aiPatchOllamaResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("ollama response decode failed: %w", err) + } + if result.Error != "" { + return "", fmt.Errorf("ollama error: %s", result.Error) + } + + return strings.TrimSpace(result.Response), nil +} + +type aiPatchOpenAIRequest struct { + Model string `json:"model"` + Messages []aiPatchOpenAIMessage `json:"messages"` +} + +type aiPatchOpenAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type aiPatchOpenAIResponse struct { + Choices []struct { + Message aiPatchOpenAIMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (p *AIPatchProvider) callOpenAI(ctx context.Context, prompt string) (string, error) { + body, _ := json.Marshal(aiPatchOpenAIRequest{ + Model: p.cfg.Model, + Messages: []aiPatchOpenAIMessage{ + {Role: "user", Content: prompt}, + }, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/chat/completions", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("openai request creation failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey) + + resp, err := p.http.Do(req) + if err != nil { + return "", fmt.Errorf("openai request failed: %w", err) + } + defer resp.Body.Close() + + rawBody, _ := io.ReadAll(resp.Body) + var result aiPatchOpenAIResponse + if err := json.Unmarshal(rawBody, &result); err != nil { + return "", fmt.Errorf("openai response decode failed: %w", err) + } + if result.Error != nil { + return "", fmt.Errorf("openai error: %s", result.Error.Message) + } + if len(result.Choices) == 0 { + return "", fmt.Errorf("openai returned no choices") + } + + return strings.TrimSpace(result.Choices[0].Message.Content), nil +} + +type aiPatchAnthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []aiPatchAnthropicMessage `json:"messages"` +} + +type aiPatchAnthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type aiPatchAnthropicResponse struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (p *AIPatchProvider) callAnthropic(ctx context.Context, prompt string) (string, error) { + body, _ := json.Marshal(aiPatchAnthropicRequest{ + Model: p.cfg.Model, + MaxTokens: 1024, + Messages: []aiPatchAnthropicMessage{ + {Role: "user", Content: prompt}, + }, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.anthropic.com/v1/messages", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("anthropic request creation failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", p.cfg.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := p.http.Do(req) + if err != nil { + return "", fmt.Errorf("anthropic request failed: %w", err) + } + defer resp.Body.Close() + + rawBody, _ := io.ReadAll(resp.Body) + var result aiPatchAnthropicResponse + if err := json.Unmarshal(rawBody, &result); err != nil { + return "", fmt.Errorf("anthropic response decode failed: %w", err) + } + if result.Error != nil { + return "", fmt.Errorf("anthropic error: %s", result.Error.Message) + } + if len(result.Content) == 0 { + return "", fmt.Errorf("anthropic returned empty content") + } + + return strings.TrimSpace(result.Content[0].Text), nil +} diff --git a/internal/remediation/ai_patch_test.go b/internal/remediation/ai_patch_test.go new file mode 100644 index 0000000..c69e96e --- /dev/null +++ b/internal/remediation/ai_patch_test.go @@ -0,0 +1,59 @@ +package remediation + +import "testing" + +func TestNewAIPatchProviderDefaults(t *testing.T) { + provider := NewAIPatchProvider(AIPatchConfig{}) + + if provider.cfg.Endpoint != "http://localhost:11434" { + t.Fatalf("expected default endpoint, got %s", provider.cfg.Endpoint) + } + if provider.cfg.Model != "llama3" { + t.Fatalf("expected default ollama model, got %s", provider.cfg.Model) + } + if provider.Provider() != "ollama" { + t.Fatalf("expected default provider ollama, got %s", provider.Provider()) + } +} + +func TestAIPatchProviderSupports(t *testing.T) { + provider := NewAIPatchProvider(AIPatchConfig{Provider: "openai"}) + + request := PatchRequest{ + Finding: Finding{ + File: "go.mod", + }, + Recommendation: Recommendation{ + Title: "Upgrade vulnerable dependency", + }, + } + + if !provider.Supports(request) { + t.Fatal("expected provider to support request with finding file and recommendation title") + } + + request.Recommendation.Title = "" + if provider.Supports(request) { + t.Fatal("expected provider not to support request without recommendation title") + } +} + +func TestParsePatchResponse(t *testing.T) { + patch, err := parsePatchResponse(`extra text {"file":"go.mod","start_line":5,"end_line":5,"original":"old","replacement":"new"} trailing text`) + if err != nil { + t.Fatalf("expected patch response to parse: %v", err) + } + + if patch.File != "go.mod" { + t.Fatalf("expected file go.mod, got %s", patch.File) + } + if patch.Replacement != "new" { + t.Fatalf("expected replacement new, got %s", patch.Replacement) + } +} + +func TestParsePatchResponseRejectsMissingJSON(t *testing.T) { + if _, err := parsePatchResponse("no json here"); err == nil { + t.Fatal("expected error for response without JSON object") + } +} diff --git a/internal/remediation/apply.go b/internal/remediation/apply.go new file mode 100644 index 0000000..65ddd3e --- /dev/null +++ b/internal/remediation/apply.go @@ -0,0 +1,222 @@ +package remediation + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ApplyPatchRequest describes a safe patch application operation. +type ApplyPatchRequest struct { + RepositoryDir string `json:"repository_dir"` + BaseBranch string `json:"base_branch,omitempty"` + BranchName string `json:"branch_name"` + Patch Patch `json:"patch"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ApplyPatchResult contains the outcome of applying a patch. +type ApplyPatchResult struct { + BranchName string `json:"branch_name"` + ChangedFiles []GitFileStatus `json:"changed_files,omitempty"` + Diff *GitDiffResult `json:"diff,omitempty"` + Patch Patch `json:"patch"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ApplyPatch applies a patch on a new branch and rolls back if any step fails. +func (e *Engine) ApplyPatch(ctx context.Context, request ApplyPatchRequest) (*ApplyPatchResult, error) { + if e.options.DryRun { + return nil, fmt.Errorf("cannot apply patch while engine is in dry run mode") + } + if e.gitClient == nil { + return nil, fmt.Errorf("git client is required to apply patches") + } + if request.RepositoryDir == "" { + return nil, fmt.Errorf("repository directory is required") + } + if request.BranchName == "" { + return nil, fmt.Errorf("branch name is required") + } + + branch, err := e.gitClient.CreateBranch(ctx, GitBranchRequest{ + RepositoryDir: request.RepositoryDir, + BaseBranch: request.BaseBranch, + BranchName: request.BranchName, + Metadata: request.Metadata, + }) + if err != nil { + return nil, fmt.Errorf("failed to create remediation branch: %w", err) + } + + if err := applyPatchToFile(request.RepositoryDir, request.Patch); err != nil { + return nil, e.rollbackPatch(ctx, request, fmt.Errorf("failed to apply patch: %w", err)) + } + + status, err := e.gitClient.ChangedFiles(ctx, GitStatusRequest{ + RepositoryDir: request.RepositoryDir, + Files: []string{request.Patch.File}, + Metadata: request.Metadata, + }) + if err != nil { + return nil, e.rollbackPatch(ctx, request, fmt.Errorf("failed to detect changed files: %w", err)) + } + if len(status.Files) == 0 { + return nil, e.rollbackPatch(ctx, request, fmt.Errorf("no changed files detected after applying patch")) + } + + diff, err := e.gitClient.Diff(ctx, GitDiffRequest{ + RepositoryDir: request.RepositoryDir, + BaseRef: request.BaseBranch, + HeadRef: branch.BranchName, + Files: changedFilePaths(status.Files), + Metadata: request.Metadata, + }) + if err != nil { + return nil, e.rollbackPatch(ctx, request, fmt.Errorf("failed to get remediation diff: %w", err)) + } + + return &ApplyPatchResult{ + BranchName: branch.BranchName, + ChangedFiles: status.Files, + Diff: diff, + Patch: request.Patch, + Metadata: request.Metadata, + }, nil +} + +func (e *Engine) rollbackPatch(ctx context.Context, request ApplyPatchRequest, cause error) error { + if _, err := e.gitClient.Rollback(ctx, GitRollbackRequest{ + RepositoryDir: request.RepositoryDir, + TargetRef: request.BaseBranch, + Files: []string{request.Patch.File}, + Metadata: request.Metadata, + }); err != nil { + return fmt.Errorf("%w; rollback failed: %v", cause, err) + } + + if _, err := e.gitClient.RestoreFiles(ctx, GitRestoreRequest{ + RepositoryDir: request.RepositoryDir, + Files: []string{request.Patch.File}, + SourceRef: request.BaseBranch, + Metadata: request.Metadata, + }); err != nil { + return fmt.Errorf("%w; restore failed: %v", cause, err) + } + + return cause +} + +func applyPatchToFile(repositoryDir string, patch Patch) error { + path, err := resolvePatchPath(repositoryDir, patch.File) + if err != nil { + return err + } + + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat patch file: %w", err) + } + if info.IsDir() { + return fmt.Errorf("patch file is a directory: %s", patch.File) + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read patch file: %w", err) + } + + content := string(data) + next, err := applyPatchContent(content, patch) + if err != nil { + return err + } + + if err := os.WriteFile(path, []byte(next), info.Mode()); err != nil { + return fmt.Errorf("failed to write patch file: %w", err) + } + + return nil +} + +func applyPatchContent(content string, patch Patch) (string, error) { + if patch.Original != "" { + count := strings.Count(content, patch.Original) + if count == 0 { + return "", fmt.Errorf("original patch content was not found") + } + if count > 1 { + return "", fmt.Errorf("original patch content is ambiguous") + } + return strings.Replace(content, patch.Original, patch.Replacement, 1), nil + } + + if patch.StartLine <= 0 || patch.EndLine < patch.StartLine { + return "", fmt.Errorf("patch requires original content or a valid line range") + } + + lines, newline := splitLines(content) + if patch.EndLine > len(lines) { + return "", fmt.Errorf("patch line range exceeds file length") + } + + replacement := splitReplacement(patch.Replacement) + next := append([]string{}, lines[:patch.StartLine-1]...) + next = append(next, replacement...) + next = append(next, lines[patch.EndLine:]...) + + return strings.Join(next, newline), nil +} + +func resolvePatchPath(repositoryDir, patchFile string) (string, error) { + if patchFile == "" { + return "", fmt.Errorf("patch file is required") + } + if filepath.IsAbs(patchFile) { + return "", fmt.Errorf("patch file must be relative to the repository") + } + + root, err := filepath.Abs(repositoryDir) + if err != nil { + return "", fmt.Errorf("failed to resolve repository directory: %w", err) + } + + path, err := filepath.Abs(filepath.Join(root, filepath.Clean(patchFile))) + if err != nil { + return "", fmt.Errorf("failed to resolve patch file: %w", err) + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return "", fmt.Errorf("failed to validate patch file path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("patch file escapes repository directory") + } + + return path, nil +} + +func splitLines(content string) ([]string, string) { + newline := "\n" + if strings.Contains(content, "\r\n") { + newline = "\r\n" + } + normalized := strings.ReplaceAll(content, "\r\n", "\n") + return strings.Split(normalized, "\n"), newline +} + +func splitReplacement(replacement string) []string { + normalized := strings.ReplaceAll(replacement, "\r\n", "\n") + return strings.Split(normalized, "\n") +} + +func changedFilePaths(files []GitFileStatus) []string { + paths := make([]string, 0, len(files)) + for _, file := range files { + paths = append(paths, file.Path) + } + return paths +} diff --git a/internal/remediation/apply_test.go b/internal/remediation/apply_test.go new file mode 100644 index 0000000..d8d416d --- /dev/null +++ b/internal/remediation/apply_test.go @@ -0,0 +1,181 @@ +package remediation + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" +) + +type fakeGitClient struct { + createBranchCalls int + changedFilesCalls int + diffCalls int + rollbackCalls int + restoreCalls int + changedFilesErr error + diffErr error + changedFiles []GitFileStatus + restoreFunc func(GitRestoreRequest) error +} + +func (g *fakeGitClient) CreateBranch(ctx context.Context, request GitBranchRequest) (*GitBranchResult, error) { + g.createBranchCalls++ + return &GitBranchResult{BranchName: request.BranchName, BaseBranch: request.BaseBranch}, nil +} + +func (g *fakeGitClient) Diff(ctx context.Context, request GitDiffRequest) (*GitDiffResult, error) { + g.diffCalls++ + if g.diffErr != nil { + return nil, g.diffErr + } + return &GitDiffResult{ + BaseRef: request.BaseRef, + HeadRef: request.HeadRef, + Files: []GitFileDiff{ + {Path: request.Files[0], Status: "modified"}, + }, + }, nil +} + +func (g *fakeGitClient) Rollback(ctx context.Context, request GitRollbackRequest) (*GitRollbackResult, error) { + g.rollbackCalls++ + return &GitRollbackResult{TargetRef: request.TargetRef, Files: request.Files}, nil +} + +func (g *fakeGitClient) HasChanges(ctx context.Context, request GitStatusRequest) (bool, error) { + return len(g.changedFiles) > 0, nil +} + +func (g *fakeGitClient) ChangedFiles(ctx context.Context, request GitStatusRequest) (*GitStatusResult, error) { + g.changedFilesCalls++ + if g.changedFilesErr != nil { + return nil, g.changedFilesErr + } + return &GitStatusResult{HasChanges: len(g.changedFiles) > 0, Files: g.changedFiles}, nil +} + +func (g *fakeGitClient) RestoreFiles(ctx context.Context, request GitRestoreRequest) (*GitRestoreResult, error) { + g.restoreCalls++ + if g.restoreFunc != nil { + if err := g.restoreFunc(request); err != nil { + return nil, err + } + } + return &GitRestoreResult{Files: request.Files, SourceRef: request.SourceRef}, nil +} + +func TestApplyPatchCreatesBranchAndReturnsChangedFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + if err := os.WriteFile(path, []byte("package main\n\nconst version = \"old\"\n"), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + git := &fakeGitClient{ + changedFiles: []GitFileStatus{ + {Path: "main.go", Status: "modified"}, + }, + } + engine := NewEngineWithGit(Options{}, nil, nil, nil, git, nil, nil) + + result, err := engine.ApplyPatch(context.Background(), ApplyPatchRequest{ + RepositoryDir: dir, + BaseBranch: "main", + BranchName: "remediation/test", + Patch: Patch{ + File: "main.go", + Original: `const version = "old"`, + Replacement: `const version = "new"`, + }, + }) + if err != nil { + t.Fatalf("expected patch to apply: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read patched file: %v", err) + } + if string(data) != "package main\n\nconst version = \"new\"\n" { + t.Fatalf("unexpected patched content: %s", string(data)) + } + if result.BranchName != "remediation/test" { + t.Fatalf("expected branch remediation/test, got %s", result.BranchName) + } + if len(result.ChangedFiles) != 1 || result.ChangedFiles[0].Path != "main.go" { + t.Fatalf("expected exact changed file main.go, got %#v", result.ChangedFiles) + } + if git.createBranchCalls != 1 || git.changedFilesCalls != 1 || git.diffCalls != 1 { + t.Fatalf("expected create, changed files, and diff calls; got create=%d changed=%d diff=%d", git.createBranchCalls, git.changedFilesCalls, git.diffCalls) + } + if git.rollbackCalls != 0 || git.restoreCalls != 0 { + t.Fatalf("did not expect rollback or restore; got rollback=%d restore=%d", git.rollbackCalls, git.restoreCalls) + } +} + +func TestApplyPatchRollsBackWhenChangeDetectionFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + original := "package main\n\nconst version = \"old\"\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + git := &fakeGitClient{ + changedFilesErr: fmt.Errorf("status failed"), + restoreFunc: func(request GitRestoreRequest) error { + return os.WriteFile(filepath.Join(dir, request.Files[0]), []byte(original), 0o644) + }, + } + engine := NewEngineWithGit(Options{}, nil, nil, nil, git, nil, nil) + + _, err := engine.ApplyPatch(context.Background(), ApplyPatchRequest{ + RepositoryDir: dir, + BaseBranch: "main", + BranchName: "remediation/test", + Patch: Patch{ + File: "main.go", + Original: `const version = "old"`, + Replacement: `const version = "new"`, + }, + }) + if err == nil { + t.Fatal("expected apply patch to fail") + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("failed to read restored file: %v", readErr) + } + if string(data) != original { + t.Fatalf("expected file to be restored, got %s", string(data)) + } + if git.rollbackCalls != 1 || git.restoreCalls != 1 { + t.Fatalf("expected rollback and restore calls; got rollback=%d restore=%d", git.rollbackCalls, git.restoreCalls) + } +} + +func TestApplyPatchRejectsPathTraversal(t *testing.T) { + dir := t.TempDir() + git := &fakeGitClient{} + engine := NewEngineWithGit(Options{}, nil, nil, nil, git, nil, nil) + + _, err := engine.ApplyPatch(context.Background(), ApplyPatchRequest{ + RepositoryDir: dir, + BaseBranch: "main", + BranchName: "remediation/test", + Patch: Patch{ + File: "../outside.go", + Original: "old", + Replacement: "new", + }, + }) + if err == nil { + t.Fatal("expected path traversal to be rejected") + } + if git.rollbackCalls != 1 || git.restoreCalls != 1 { + t.Fatalf("expected rollback and restore after branch creation; got rollback=%d restore=%d", git.rollbackCalls, git.restoreCalls) + } +} diff --git a/internal/remediation/engine.go b/internal/remediation/engine.go new file mode 100644 index 0000000..93dd529 --- /dev/null +++ b/internal/remediation/engine.go @@ -0,0 +1,97 @@ +package remediation + +// Engine coordinates remediation providers and validators. +type Engine struct { + options Options + findingProviders []FindingProvider + recommendationProviders []RecommendationProvider + patchProviders []PatchProvider + gitClient GitClient + validationEngine ValidationEngine + providers []Provider + validators []Validator +} + +// NewEngine creates a remediation engine with the provided dependencies. +func NewEngine(options Options, providers []Provider, validators []Validator) *Engine { + return &Engine{ + options: options, + providers: append([]Provider(nil), providers...), + validators: append([]Validator(nil), validators...), + } +} + +// NewEngineWithFindingProviders creates a remediation engine with finding providers. +func NewEngineWithFindingProviders(options Options, findingProviders []FindingProvider, providers []Provider, validators []Validator) *Engine { + engine := NewEngine(options, providers, validators) + engine.findingProviders = append([]FindingProvider(nil), findingProviders...) + return engine +} + +// NewEngineWithRecommendationProviders creates a remediation engine with recommendation providers. +func NewEngineWithRecommendationProviders(options Options, findingProviders []FindingProvider, recommendationProviders []RecommendationProvider, providers []Provider, validators []Validator) *Engine { + engine := NewEngineWithFindingProviders(options, findingProviders, providers, validators) + engine.recommendationProviders = append([]RecommendationProvider(nil), recommendationProviders...) + return engine +} + +// NewEngineWithPatchProviders creates a remediation engine with AI patch providers. +func NewEngineWithPatchProviders(options Options, findingProviders []FindingProvider, recommendationProviders []RecommendationProvider, patchProviders []PatchProvider, providers []Provider, validators []Validator) *Engine { + engine := NewEngineWithRecommendationProviders(options, findingProviders, recommendationProviders, providers, validators) + engine.patchProviders = append([]PatchProvider(nil), patchProviders...) + return engine +} + +// NewEngineWithGit creates a remediation engine with a Git client. +func NewEngineWithGit(options Options, findingProviders []FindingProvider, recommendationProviders []RecommendationProvider, patchProviders []PatchProvider, gitClient GitClient, providers []Provider, validators []Validator) *Engine { + engine := NewEngineWithPatchProviders(options, findingProviders, recommendationProviders, patchProviders, providers, validators) + engine.gitClient = gitClient + return engine +} + +// NewEngineWithValidation creates a remediation engine with validation support. +func NewEngineWithValidation(options Options, findingProviders []FindingProvider, recommendationProviders []RecommendationProvider, patchProviders []PatchProvider, gitClient GitClient, validationEngine ValidationEngine, providers []Provider, validators []Validator) *Engine { + engine := NewEngineWithGit(options, findingProviders, recommendationProviders, patchProviders, gitClient, providers, validators) + engine.validationEngine = validationEngine + return engine +} + +// Options returns the engine configuration. +func (e *Engine) Options() Options { + return e.options +} + +// FindingProviders returns the configured finding providers. +func (e *Engine) FindingProviders() []FindingProvider { + return append([]FindingProvider(nil), e.findingProviders...) +} + +// RecommendationProviders returns the configured recommendation providers. +func (e *Engine) RecommendationProviders() []RecommendationProvider { + return append([]RecommendationProvider(nil), e.recommendationProviders...) +} + +// PatchProviders returns the configured AI patch providers. +func (e *Engine) PatchProviders() []PatchProvider { + return append([]PatchProvider(nil), e.patchProviders...) +} + +// GitClient returns the configured Git client. +func (e *Engine) GitClient() GitClient { + return e.gitClient +} + +// ValidationEngine returns the configured validation engine. +func (e *Engine) ValidationEngine() ValidationEngine { + return e.validationEngine +} + +// Providers returns the configured remediation providers. +func (e *Engine) Providers() []Provider { + return append([]Provider(nil), e.providers...) +} + +// Validators returns the configured recommendation validators. +func (e *Engine) Validators() []Validator { + return append([]Validator(nil), e.validators...) +} diff --git a/internal/remediation/flow.go b/internal/remediation/flow.go new file mode 100644 index 0000000..d420a41 --- /dev/null +++ b/internal/remediation/flow.go @@ -0,0 +1,130 @@ +package remediation + +import ( + "context" + "fmt" +) + +// RemediationRequest describes a complete remediation workflow for one finding. +type RemediationRequest struct { + RepositoryDir string `json:"repository_dir"` + BaseBranch string `json:"base_branch,omitempty"` + BranchName string `json:"branch_name"` + Finding Finding `json:"finding"` + Recommendation Recommendation `json:"recommendation"` + Patch Patch `json:"patch"` + Checks []ValidationType `json:"checks,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// RemediationResult contains the complete remediation workflow outcome. +type RemediationResult struct { + Status string `json:"status"` + Applied *ApplyPatchResult `json:"applied,omitempty"` + Validation *ValidationResult `json:"validation,omitempty"` + Summary RemediationSummary `json:"summary"` + Error error `json:"-"` +} + +// RemediationSummary contains counts that can be shown by the CLI. +type RemediationSummary struct { + VulnerabilitiesFixed int `json:"vulnerabilities_fixed"` + VulnerabilitiesPending int `json:"vulnerabilities_pending"` + ValidationsPassed int `json:"validations_passed"` + ValidationsFailed int `json:"validations_failed"` +} + +// Remediate applies a patch, validates it, and rolls back on validation failure. +func (e *Engine) Remediate(ctx context.Context, request RemediationRequest) (*RemediationResult, error) { + if e.validationEngine == nil { + return nil, fmt.Errorf("validation engine is required to complete remediation") + } + + applyRequest := ApplyPatchRequest{ + RepositoryDir: request.RepositoryDir, + BaseBranch: request.BaseBranch, + BranchName: request.BranchName, + Patch: request.Patch, + Metadata: request.Metadata, + } + + applied, err := e.ApplyPatch(ctx, applyRequest) + if err != nil { + result := failedRemediationResult(nil, nil, err) + return result, err + } + + validation, err := e.validationEngine.Validate(ctx, ValidationRequest{ + ProjectDir: request.RepositoryDir, + Finding: request.Finding, + Recommendation: request.Recommendation, + Patches: []Patch{request.Patch}, + Diff: applied.Diff, + Checks: request.Checks, + Metadata: request.Metadata, + }) + if err != nil { + rollbackErr := e.rollbackPatch(ctx, applyRequest, fmt.Errorf("validation failed: %w", err)) + result := failedRemediationResult(applied, validation, rollbackErr) + return result, rollbackErr + } + + if validation == nil { + rollbackErr := e.rollbackPatch(ctx, applyRequest, fmt.Errorf("validation engine returned no result")) + result := failedRemediationResult(applied, nil, rollbackErr) + return result, rollbackErr + } + + if !validation.Passed { + rollbackErr := e.rollbackPatch(ctx, applyRequest, fmt.Errorf("one or more validations failed")) + result := failedRemediationResult(applied, validation, rollbackErr) + return result, rollbackErr + } + + return &RemediationResult{ + Status: "success", + Applied: applied, + Validation: validation, + Summary: buildRemediationSummary(validation, true), + }, nil +} + +func failedRemediationResult(applied *ApplyPatchResult, validation *ValidationResult, err error) *RemediationResult { + return &RemediationResult{ + Status: "failed", + Applied: applied, + Validation: validation, + Summary: buildRemediationSummary(validation, false), + Error: err, + } +} + +func buildRemediationSummary(validation *ValidationResult, fixed bool) RemediationSummary { + summary := RemediationSummary{} + if fixed { + summary.VulnerabilitiesFixed = 1 + } else { + summary.VulnerabilitiesPending = 1 + } + + if validation == nil { + if !fixed { + summary.ValidationsFailed = 1 + } + return summary + } + + for _, check := range validation.Checks { + if check.Passed { + summary.ValidationsPassed++ + } else { + summary.ValidationsFailed++ + } + } + + if len(validation.Checks) == 0 && !validation.Passed { + summary.ValidationsFailed = 1 + } + + return summary +} diff --git a/internal/remediation/flow_test.go b/internal/remediation/flow_test.go new file mode 100644 index 0000000..13d48b2 --- /dev/null +++ b/internal/remediation/flow_test.go @@ -0,0 +1,201 @@ +package remediation + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" +) + +type fakeValidationEngine struct { + result *ValidationResult + err error + calls int +} + +func (v *fakeValidationEngine) Validate(ctx context.Context, request ValidationRequest) (*ValidationResult, error) { + v.calls++ + if v.err != nil { + return nil, v.err + } + return v.result, nil +} + +func TestRemediateAppliesPatchAndValidates(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + if err := os.WriteFile(path, []byte("package main\n\nconst version = \"old\"\n"), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + git := &fakeGitClient{ + changedFiles: []GitFileStatus{ + {Path: "main.go", Status: "modified"}, + }, + } + validation := &fakeValidationEngine{ + result: &ValidationResult{ + Status: "success", + Passed: true, + Checks: []ValidationCheckResult{ + {Name: "build", Type: "build", Status: "success", Passed: true}, + {Name: "unit", Type: "unit_tests", Status: "success", Passed: true}, + }, + }, + } + engine := NewEngineWithValidation(Options{}, nil, nil, nil, git, validation, nil, nil) + + result, err := engine.Remediate(context.Background(), RemediationRequest{ + RepositoryDir: dir, + BaseBranch: "main", + BranchName: "remediation/test", + Finding: Finding{ + File: "main.go", + Severity: "HIGH", + Message: "old version", + }, + Recommendation: Recommendation{ + Title: "Use new version", + }, + Patch: Patch{ + File: "main.go", + Original: `const version = "old"`, + Replacement: `const version = "new"`, + }, + Checks: []ValidationType{"build", "unit_tests", "security_rescan"}, + }) + if err != nil { + t.Fatalf("expected remediation to succeed: %v", err) + } + + if result.Status != "success" { + t.Fatalf("expected success status, got %s", result.Status) + } + if result.Summary.VulnerabilitiesFixed != 1 || result.Summary.VulnerabilitiesPending != 0 { + t.Fatalf("unexpected vulnerability summary: %#v", result.Summary) + } + if result.Summary.ValidationsPassed != 2 || result.Summary.ValidationsFailed != 0 { + t.Fatalf("unexpected validation summary: %#v", result.Summary) + } + if validation.calls != 1 { + t.Fatalf("expected validation to run once, got %d", validation.calls) + } + if git.rollbackCalls != 0 || git.restoreCalls != 0 { + t.Fatalf("did not expect rollback or restore; got rollback=%d restore=%d", git.rollbackCalls, git.restoreCalls) + } +} + +func TestRemediateRollsBackWhenValidationFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + original := "package main\n\nconst version = \"old\"\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + git := &fakeGitClient{ + changedFiles: []GitFileStatus{ + {Path: "main.go", Status: "modified"}, + }, + restoreFunc: func(request GitRestoreRequest) error { + return os.WriteFile(filepath.Join(dir, request.Files[0]), []byte(original), 0o644) + }, + } + validation := &fakeValidationEngine{ + result: &ValidationResult{ + Status: "failed", + Passed: false, + Checks: []ValidationCheckResult{ + {Name: "build", Type: "build", Status: "success", Passed: true}, + {Name: "security", Type: "security_rescan", Status: "failed", Passed: false}, + }, + }, + } + engine := NewEngineWithValidation(Options{}, nil, nil, nil, git, validation, nil, nil) + + result, err := engine.Remediate(context.Background(), RemediationRequest{ + RepositoryDir: dir, + BaseBranch: "main", + BranchName: "remediation/test", + Finding: Finding{ + File: "main.go", + Severity: "HIGH", + Message: "old version", + }, + Recommendation: Recommendation{Title: "Use new version"}, + Patch: Patch{ + File: "main.go", + Original: `const version = "old"`, + Replacement: `const version = "new"`, + }, + }) + if err == nil { + t.Fatal("expected remediation to fail") + } + if result == nil { + t.Fatal("expected failed result") + } + if result.Summary.VulnerabilitiesFixed != 0 || result.Summary.VulnerabilitiesPending != 1 { + t.Fatalf("unexpected vulnerability summary: %#v", result.Summary) + } + if result.Summary.ValidationsPassed != 1 || result.Summary.ValidationsFailed != 1 { + t.Fatalf("unexpected validation summary: %#v", result.Summary) + } + if git.rollbackCalls != 1 || git.restoreCalls != 1 { + t.Fatalf("expected rollback and restore; got rollback=%d restore=%d", git.rollbackCalls, git.restoreCalls) + } + + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("failed to read restored file: %v", readErr) + } + if string(data) != original { + t.Fatalf("expected restored content, got %s", string(data)) + } +} + +func TestRemediateRollsBackWhenValidationErrors(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.go") + original := "package main\n\nconst version = \"old\"\n" + if err := os.WriteFile(path, []byte(original), 0o644); err != nil { + t.Fatalf("failed to write fixture: %v", err) + } + + git := &fakeGitClient{ + changedFiles: []GitFileStatus{ + {Path: "main.go", Status: "modified"}, + }, + restoreFunc: func(request GitRestoreRequest) error { + return os.WriteFile(filepath.Join(dir, request.Files[0]), []byte(original), 0o644) + }, + } + validation := &fakeValidationEngine{err: fmt.Errorf("validation crashed")} + engine := NewEngineWithValidation(Options{}, nil, nil, nil, git, validation, nil, nil) + + result, err := engine.Remediate(context.Background(), RemediationRequest{ + RepositoryDir: dir, + BaseBranch: "main", + BranchName: "remediation/test", + Finding: Finding{File: "main.go", Severity: "HIGH"}, + Recommendation: Recommendation{Title: "Use new version"}, + Patch: Patch{ + File: "main.go", + Original: `const version = "old"`, + Replacement: `const version = "new"`, + }, + }) + if err == nil { + t.Fatal("expected remediation to fail") + } + if result == nil { + t.Fatal("expected failed result") + } + if result.Summary.ValidationsFailed != 1 { + t.Fatalf("expected one failed validation, got %#v", result.Summary) + } + if git.rollbackCalls != 1 || git.restoreCalls != 1 { + t.Fatalf("expected rollback and restore; got rollback=%d restore=%d", git.rollbackCalls, git.restoreCalls) + } +} diff --git a/internal/remediation/git.go b/internal/remediation/git.go new file mode 100644 index 0000000..7cdf66c --- /dev/null +++ b/internal/remediation/git.go @@ -0,0 +1,105 @@ +package remediation + +import "context" + +// GitClient defines repository operations needed by remediation workflows. +type GitClient interface { + CreateBranch(ctx context.Context, request GitBranchRequest) (*GitBranchResult, error) + Diff(ctx context.Context, request GitDiffRequest) (*GitDiffResult, error) + Rollback(ctx context.Context, request GitRollbackRequest) (*GitRollbackResult, error) + HasChanges(ctx context.Context, request GitStatusRequest) (bool, error) + ChangedFiles(ctx context.Context, request GitStatusRequest) (*GitStatusResult, error) + RestoreFiles(ctx context.Context, request GitRestoreRequest) (*GitRestoreResult, error) +} + +// GitBranchRequest describes a branch creation request. +type GitBranchRequest struct { + RepositoryDir string `json:"repository_dir,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + BranchName string `json:"branch_name"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitBranchResult contains branch creation details. +type GitBranchResult struct { + BranchName string `json:"branch_name"` + BaseBranch string `json:"base_branch,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitDiffRequest describes which repository changes should be diffed. +type GitDiffRequest struct { + RepositoryDir string `json:"repository_dir,omitempty"` + BaseRef string `json:"base_ref,omitempty"` + HeadRef string `json:"head_ref,omitempty"` + Files []string `json:"files,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitDiffResult contains a repository diff. +type GitDiffResult struct { + BaseRef string `json:"base_ref,omitempty"` + HeadRef string `json:"head_ref,omitempty"` + Files []GitFileDiff `json:"files,omitempty"` + Patch string `json:"patch,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitFileDiff contains the diff for a single file. +type GitFileDiff struct { + Path string `json:"path"` + Status string `json:"status,omitempty"` + Patch string `json:"patch,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitRollbackRequest describes a rollback operation. +type GitRollbackRequest struct { + RepositoryDir string `json:"repository_dir,omitempty"` + TargetRef string `json:"target_ref,omitempty"` + Files []string `json:"files,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitRollbackResult contains rollback details. +type GitRollbackResult struct { + TargetRef string `json:"target_ref,omitempty"` + Files []string `json:"files,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitStatusRequest describes a repository status query. +type GitStatusRequest struct { + RepositoryDir string `json:"repository_dir,omitempty"` + Files []string `json:"files,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitStatusResult contains changed file information. +type GitStatusResult struct { + HasChanges bool `json:"has_changes"` + Files []GitFileStatus `json:"files,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitFileStatus describes the status of a changed file. +type GitFileStatus struct { + Path string `json:"path"` + Status string `json:"status"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitRestoreRequest describes files that should be restored. +type GitRestoreRequest struct { + RepositoryDir string `json:"repository_dir,omitempty"` + Files []string `json:"files"` + SourceRef string `json:"source_ref,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// GitRestoreResult contains restore details. +type GitRestoreResult struct { + Files []string `json:"files"` + SourceRef string `json:"source_ref,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} diff --git a/internal/remediation/interfaces.go b/internal/remediation/interfaces.go new file mode 100644 index 0000000..b671c41 --- /dev/null +++ b/internal/remediation/interfaces.go @@ -0,0 +1,39 @@ +package remediation + +import "context" + +// FindingProvider returns normalized findings from a source. +type FindingProvider interface { + Name() string + Source() FindingSource + Findings(ctx context.Context, request FindingRequest) (*FindingSet, error) +} + +// RecommendationProvider returns official recommendations for findings. +type RecommendationProvider interface { + Name() string + Source() RecommendationSource + Supports(finding Finding) bool + Recommendations(ctx context.Context, request RecommendationRequest) (*RecommendationSet, error) +} + +// PatchProvider generates structured patches from findings and recommendations. +type PatchProvider interface { + Name() string + Provider() AIProvider + Supports(request PatchRequest) bool + GeneratePatch(ctx context.Context, request PatchRequest) (*PatchResponse, error) +} + +// Provider creates remediation recommendations for supported findings. +type Provider interface { + Name() string + Supports(finding Finding) bool + Recommend(ctx context.Context, finding Finding) (*Recommendation, error) +} + +// Validator verifies whether a recommendation is safe to apply. +type Validator interface { + Name() string + Validate(ctx context.Context, recommendation Recommendation) error +} diff --git a/internal/remediation/types.go b/internal/remediation/types.go new file mode 100644 index 0000000..64c88cb --- /dev/null +++ b/internal/remediation/types.go @@ -0,0 +1,141 @@ +package remediation + +// Finding represents a security issue that can be evaluated for remediation. +type Finding struct { + Tool string `json:"tool"` + RuleID string `json:"rule_id,omitempty"` + Severity string `json:"severity"` + File string `json:"file"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Message string `json:"message"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// FindingSource identifies where normalized findings came from. +type FindingSource string + +// FindingRequest describes the input needed to collect findings. +type FindingRequest struct { + ProjectDir string `json:"project_dir,omitempty"` + Sources []FindingSource `json:"sources,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// FindingSet contains normalized findings returned by a finding provider. +type FindingSet struct { + Provider string `json:"provider"` + Source FindingSource `json:"source,omitempty"` + Findings []Finding `json:"findings"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// RecommendationSource identifies where a recommendation came from. +type RecommendationSource string + +// RecommendationRequest describes the input needed to resolve official recommendations. +type RecommendationRequest struct { + Finding Finding `json:"finding"` + Sources []RecommendationSource `json:"sources,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// RecommendationSet contains recommendations returned by a recommendation provider. +type RecommendationSet struct { + Provider string `json:"provider"` + Source RecommendationSource `json:"source,omitempty"` + Recommendations []Recommendation `json:"recommendations"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// AIProvider identifies an AI backend that can generate patches. +type AIProvider string + +// PatchRequest describes the input needed to generate a structured patch. +type PatchRequest struct { + Finding Finding `json:"finding"` + Recommendation Recommendation `json:"recommendation"` + ProjectDir string `json:"project_dir,omitempty"` + Files []PatchFile `json:"files,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// PatchFile contains file context used by a patch provider. +type PatchFile struct { + Path string `json:"path"` + Content string `json:"content,omitempty"` + Language string `json:"language,omitempty"` +} + +// PatchResponse contains a structured patch generated by an AI provider. +type PatchResponse struct { + Provider string `json:"provider"` + Model string `json:"model,omitempty"` + Patch Patch `json:"patch"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Reference points to official remediation or vulnerability documentation. +type Reference struct { + Title string `json:"title,omitempty"` + URL string `json:"url"` + Type string `json:"type,omitempty"` +} + +// PackageImpact describes dependency information needed to apply a recommendation. +type PackageImpact struct { + Name string `json:"name,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` + CurrentVersion string `json:"current_version,omitempty"` + FixedVersion string `json:"fixed_version,omitempty"` + File string `json:"file,omitempty"` +} + +// RemediationStep describes an exact action required by a recommendation. +type RemediationStep struct { + Action string `json:"action"` + Target string `json:"target,omitempty"` + Value string `json:"value,omitempty"` + Description string `json:"description,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Recommendation describes a proposed remediation for a finding. +type Recommendation struct { + ID string `json:"id,omitempty"` + Provider string `json:"provider,omitempty"` + Source RecommendationSource `json:"source,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Risk string `json:"risk,omitempty"` + Finding Finding `json:"finding"` + PackageImpact *PackageImpact `json:"package_impact,omitempty"` + Steps []RemediationStep `json:"steps,omitempty"` + Patches []Patch `json:"patches,omitempty"` + References []Reference `json:"references,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Patch represents a file change proposed by a remediation provider. +type Patch struct { + File string `json:"file"` + StartLine int `json:"start_line,omitempty"` + EndLine int `json:"end_line,omitempty"` + Original string `json:"original,omitempty"` + Replacement string `json:"replacement,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Result represents the outcome of a remediation attempt. +type Result struct { + Finding Finding `json:"finding"` + Recommendation *Recommendation `json:"recommendation,omitempty"` + Status string `json:"status"` + Patches []Patch `json:"patches,omitempty"` + Error error `json:"-"` +} + +// Options controls how the remediation engine is configured. +type Options struct { + DryRun bool +} diff --git a/internal/remediation/validation.go b/internal/remediation/validation.go new file mode 100644 index 0000000..014baca --- /dev/null +++ b/internal/remediation/validation.go @@ -0,0 +1,50 @@ +package remediation + +import "context" + +// ValidationEngine verifies whether applied remediation changes are safe. +type ValidationEngine interface { + Validate(ctx context.Context, request ValidationRequest) (*ValidationResult, error) +} + +// ValidationRunner executes one validation check type. +type ValidationRunner interface { + Name() string + Type() ValidationType + Validate(ctx context.Context, request ValidationRequest) (*ValidationCheckResult, error) +} + +// ValidationType identifies a validation category. +type ValidationType string + +// ValidationRequest describes changes that should be validated. +type ValidationRequest struct { + ProjectDir string `json:"project_dir,omitempty"` + Finding Finding `json:"finding"` + Recommendation Recommendation `json:"recommendation"` + Patches []Patch `json:"patches,omitempty"` + Diff *GitDiffResult `json:"diff,omitempty"` + Checks []ValidationType `json:"checks,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// ValidationResult contains the overall validation outcome. +type ValidationResult struct { + Status string `json:"status"` + Passed bool `json:"passed"` + Checks []ValidationCheckResult `json:"checks,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Error error `json:"-"` +} + +// ValidationCheckResult contains the outcome of a single validation check. +type ValidationCheckResult struct { + Name string `json:"name"` + Type ValidationType `json:"type"` + Status string `json:"status"` + Passed bool `json:"passed"` + Output string `json:"output,omitempty"` + Findings []Finding `json:"findings,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Error error `json:"-"` +} diff --git a/internal/report/evaluator.go b/internal/report/evaluator.go new file mode 100644 index 0000000..cef7e4a --- /dev/null +++ b/internal/report/evaluator.go @@ -0,0 +1,78 @@ +package report + +import "strings" + +const ( + StatusPass = "PASS" + StatusWarn = "WARN" + StatusFail = "FAIL" +) + +// Finding contains normalized data needed to evaluate scan status. +type Finding struct { + Tool string + Severity string + Blocking bool + Rule string + File string + Line int +} + +// Evaluation contains the global scan status and blocking count. +type Evaluation struct { + Status string + BlockingCount int +} + +// Evaluate calculates the global scan status from normalized findings. +func Evaluate(findings []Finding) Evaluation { + if len(findings) == 0 { + return Evaluation{Status: StatusPass} + } + + blockingCount := 0 + for _, finding := range findings { + if isBlocking(finding) { + blockingCount++ + } + } + + if blockingCount > 0 { + return Evaluation{ + Status: StatusFail, + BlockingCount: blockingCount, + } + } + + return Evaluation{Status: StatusWarn} +} + +func isBlocking(finding Finding) bool { + if finding.Blocking { + return true + } + + switch NormalizeSeverity(finding.Severity) { + case "CRITICAL", "HIGH": + return true + default: + return false + } +} + +// NormalizeSeverity maps scanner-specific severities into the internal model. +func NormalizeSeverity(severity string) string { + s := strings.ToUpper(strings.TrimSpace(severity)) + switch s { + case "CRITICAL", "CRITICAL,HIGH", "FATAL": + return "CRITICAL" + case "ERROR", "HIGH": + return "HIGH" + case "WARNING", "WARN", "MEDIUM", "MODERATE": + return "MEDIUM" + case "LOW", "INFO", "INFORMATIONAL", "NOTE": + return "LOW" + default: + return "MEDIUM" + } +} diff --git a/internal/report/evaluator_test.go b/internal/report/evaluator_test.go new file mode 100644 index 0000000..e07fc2c --- /dev/null +++ b/internal/report/evaluator_test.go @@ -0,0 +1,51 @@ +package report + +import "testing" + +func TestEvaluateNoFindingsPass(t *testing.T) { + evaluation := Evaluate(nil) + if evaluation.Status != StatusPass { + t.Fatalf("expected PASS, got %s", evaluation.Status) + } + if evaluation.BlockingCount != 0 { + t.Fatalf("expected no blocking findings, got %d", evaluation.BlockingCount) + } +} + +func TestEvaluateLowWarn(t *testing.T) { + evaluation := Evaluate([]Finding{{Severity: "LOW"}}) + if evaluation.Status != StatusWarn { + t.Fatalf("expected WARN, got %s", evaluation.Status) + } +} + +func TestEvaluateMediumWarn(t *testing.T) { + evaluation := Evaluate([]Finding{{Severity: "MEDIUM"}}) + if evaluation.Status != StatusWarn { + t.Fatalf("expected WARN, got %s", evaluation.Status) + } +} + +func TestEvaluateHighFail(t *testing.T) { + evaluation := Evaluate([]Finding{{Severity: "HIGH"}}) + if evaluation.Status != StatusFail { + t.Fatalf("expected FAIL, got %s", evaluation.Status) + } + if evaluation.BlockingCount != 1 { + t.Fatalf("expected one blocking finding, got %d", evaluation.BlockingCount) + } +} + +func TestEvaluateCriticalFail(t *testing.T) { + evaluation := Evaluate([]Finding{{Severity: "CRITICAL"}}) + if evaluation.Status != StatusFail { + t.Fatalf("expected FAIL, got %s", evaluation.Status) + } +} + +func TestEvaluateExplicitBlockingFail(t *testing.T) { + evaluation := Evaluate([]Finding{{Severity: "LOW", Blocking: true}}) + if evaluation.Status != StatusFail { + t.Fatalf("expected FAIL, got %s", evaluation.Status) + } +} diff --git a/internal/tools/runner.go b/internal/tools/runner.go new file mode 100644 index 0000000..3ed2a54 --- /dev/null +++ b/internal/tools/runner.go @@ -0,0 +1,39 @@ +package tools + +import ( + "os" + "os/exec" + "runtime" + "strings" +) + +// Command creates an external tool command with platform-specific defaults. +func Command(name string, args ...string) *exec.Cmd { + cmd := exec.Command(name, args...) + applyEnvironment(cmd, name, runtime.GOOS) + return cmd +} + +func applyEnvironment(cmd *exec.Cmd, name, goos string) { + if goos != "windows" { + return + } + + if toolName(name) != "semgrep" { + return + } + + // Semgrep runs through Python; on Windows CP1252 can fail on Unicode paths/output. + cmd.Env = append(os.Environ(), + "PYTHONUTF8=1", + "PYTHONIOENCODING=utf-8", + ) +} + +func toolName(name string) string { + name = strings.ReplaceAll(name, "\\", "/") + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + return strings.TrimSuffix(strings.ToLower(name), ".exe") +} diff --git a/internal/tools/runner_test.go b/internal/tools/runner_test.go new file mode 100644 index 0000000..081c19b --- /dev/null +++ b/internal/tools/runner_test.go @@ -0,0 +1,51 @@ +package tools + +import ( + "os/exec" + "testing" +) + +func TestApplyEnvironmentAddsSemgrepWindowsUTF8Env(t *testing.T) { + cmd := exec.Command("semgrep") + applyEnvironment(cmd, "semgrep", "windows") + + if !hasEnv(cmd.Env, "PYTHONUTF8=1") { + t.Fatal("expected PYTHONUTF8=1 for semgrep on windows") + } + if !hasEnv(cmd.Env, "PYTHONIOENCODING=utf-8") { + t.Fatal("expected PYTHONIOENCODING=utf-8 for semgrep on windows") + } +} + +func TestApplyEnvironmentDoesNotModifySemgrepNonWindows(t *testing.T) { + cmd := exec.Command("semgrep") + applyEnvironment(cmd, "semgrep", "linux") + + if len(cmd.Env) != 0 { + t.Fatalf("expected env to remain unset on non-windows, got %#v", cmd.Env) + } +} + +func TestApplyEnvironmentDoesNotModifyOtherToolsOnWindows(t *testing.T) { + cmd := exec.Command("trivy") + applyEnvironment(cmd, "trivy", "windows") + + if len(cmd.Env) != 0 { + t.Fatalf("expected env to remain unset for non-semgrep tools, got %#v", cmd.Env) + } +} + +func TestToolNameNormalizesPathAndExtension(t *testing.T) { + if got := toolName(`C:\Tools\semgrep.exe`); got != "semgrep" { + t.Fatalf("expected semgrep, got %s", got) + } +} + +func hasEnv(env []string, keyValue string) bool { + for _, item := range env { + if item == keyValue { + return true + } + } + return false +}