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)
β οΈ %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 +}