diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 9503b949..89adeb87 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -2,6 +2,7 @@ package kics import ( "fmt" + "path/filepath" "strings" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" @@ -72,6 +73,43 @@ func permissionDecisionReason(filePath, summary string) string { ) } +// dockerImagePlatforms are the KICS "platform" values (result.Platform, sourced from +// KICS query metadata) whose findings concern container images rather than generic +// IaC misconfigurations. These line up with the fileType enum accepted by the +// imageRemediation MCP tool (Dockerfile, DockerCompose). +var dockerImagePlatforms = map[string]bool{ + "dockerfile": true, + "dockercompose": true, + "docker compose": true, +} + +// isDockerImageFinding reports whether a finding's KICS platform identifies it as a +// container image issue (Dockerfile/docker-compose) rather than generic IaC. Falls +// back to filename heuristics only when platform is unavailable (e.g. older cached +// results), since platform is scanner-reported ground truth and filenames can vary. +func isDockerImageFinding(filePath string, findings []iacrealtime.IacRealtimeResult) bool { + for _, f := range findings { + if f.Platform != "" { + return dockerImagePlatforms[strings.ToLower(f.Platform)] + } + } + return isDockerImageFileByName(filePath) +} + +// isDockerImageFileByName is a filename-based fallback for when KICS platform metadata +// isn't available. Mirrors the basename conventions in params.KicsBaseFilters plus the +// docker-compose/compose naming convention (not in KicsBaseFilters since compose files +// match on the generic .yml/.yaml extensions). +func isDockerImageFileByName(filePath string) bool { + base := strings.ToLower(filepath.Base(filePath)) + if base == "dockerfile" || strings.HasSuffix(base, ".dockerfile") { + return true + } + name := strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml") + return name == "docker-compose" || strings.HasPrefix(name, "docker-compose.") || + name == "compose" || strings.HasPrefix(name, "compose.") +} + // additionalContext is injected into the agent's context window to drive remediation. // KICS is a deterministic IaC rule engine: unlike ASCA, its findings are not caused by // missing cross-file context, so the agent is NOT given discretion to treat findings as @@ -94,19 +132,38 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult "tool or shell command.\n"+ "Fix every finding below, then retry the write:\n"+ "%s"+ - "For each finding, call the mcp__Checkmarx__codeRemediation tool with:\n"+ - " {\n"+ - " \"type\": \"iac\",\n"+ - " \"metadata\": {\n"+ - " \"title\": \"[Title from finding]\",\n"+ - " \"description\": \"[Description from finding]\",\n"+ - " \"remediationAdvice\": \"[how to harden this configuration]\"\n"+ - " }\n"+ - " }\n"+ - "Apply the remediation guidance the tool returns, then retry the write. If a fix "+ - "genuinely requires resources outside this file (for example a separate KMS key or "+ - "a centrally-managed policy), add them as part of your change rather than skipping "+ - "the finding.", - filePath, findingList.String(), + "%s", + filePath, findingList.String(), remediationInstructions(filePath, findings), ) } + +// remediationInstructions returns the tool-call guidance for the finding's file type. +// Dockerfile/docker-compose findings are about container images, so they must go +// through imageRemediation (base image CVEs, safer tags, hardening). All other +// KICS-supported files (Terraform, Kubernetes manifests, CloudFormation, etc.) are +// generic IaC misconfigurations and go through codeRemediation. +func remediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string { + if isDockerImageFinding(filePath, findings) { + return "For each finding, call the mcp__Checkmarx__imageRemediation tool with:\n" + + " {\n" + + " \"imageName\": \"[image name from the finding/file, without the tag]\",\n" + + " \"imageTag\": \"[image tag from the finding/file, e.g. latest]\",\n" + + " \"fileType\": \"[Dockerfile or DockerCompose, matching this file]\"\n" + + " }\n" + + "Apply the remediation guidance the tool returns (safer base image, pinned digest, " + + "hardening steps), then retry the write." + } + return "For each finding, call the mcp__Checkmarx__codeRemediation tool with:\n" + + " {\n" + + " \"type\": \"iac\",\n" + + " \"metadata\": {\n" + + " \"title\": \"[Title from finding]\",\n" + + " \"description\": \"[Description from finding]\",\n" + + " \"remediationAdvice\": \"[how to harden this configuration]\"\n" + + " }\n" + + " }\n" + + "Apply the remediation guidance the tool returns, then retry the write. If a fix " + + "genuinely requires resources outside this file (for example a separate KMS key or " + + "a centrally-managed policy), add them as part of your change rather than skipping " + + "the finding." +} diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index 09f6c476..4b7a99d6 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -20,6 +20,12 @@ func iacResult(title, similarityID, severity string, line int) iacrealtime.IacRe } } +func iacResultWithPlatform(title, similarityID, severity, platform string, line int) iacrealtime.IacRealtimeResult { + r := iacResult(title, similarityID, severity, line) + r.Platform = platform + return r +} + // ── NewFindings ─────────────────────────────────────────────────────────────── func TestNewFindings_NilOriginalReturnsAll(t *testing.T) { @@ -123,3 +129,86 @@ func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) { t.Errorf("context should warn against bypass, got: %q", ctx) } } + +// ── isDockerImageFinding / remediation tool routing ──────────────────────────── + +func TestIsDockerImageFinding_ByPlatform(t *testing.T) { + cases := []struct { + platform string + want bool + }{ + {"Dockerfile", true}, + {"DockerCompose", true}, + {"Docker Compose", true}, + {"dockerfile", true}, + {"Terraform", false}, + {"Kubernetes", false}, + {"CloudFormation", false}, + {"Ansible", false}, + } + for _, c := range cases { + findings := []iacrealtime.IacRealtimeResult{ + iacResultWithPlatform("SomeFinding", "sim1", "HIGH", c.platform, 1), + } + // Filename deliberately contradicts platform to prove platform wins. + if got := isDockerImageFinding("/project/values.yaml", findings); got != c.want { + t.Errorf("isDockerImageFinding with platform %q = %v, want %v", c.platform, got, c.want) + } + } +} + +func TestIsDockerImageFinding_FallsBackToFilenameWhenPlatformEmpty(t *testing.T) { + cases := map[string]bool{ + "/project/Dockerfile": true, + "/project/api.dockerfile": true, + "/project/docker-compose.yml": true, + "/project/docker-compose.yaml": true, + "/project/docker-compose.prod.yml": true, + "/project/compose.yaml": true, + "/project/main.tf": false, + "/project/deployment.yaml": false, + "/project/values.yaml": false, + } + for path, want := range cases { + findings := []iacrealtime.IacRealtimeResult{iacResult("SomeFinding", "sim1", "HIGH", 1)} + if got := isDockerImageFinding(path, findings); got != want { + t.Errorf("isDockerImageFinding(%q) with no platform = %v, want %v", path, got, want) + } + } +} + +func TestFormatFindings_DockerfilePlatformUsesImageRemediation(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{ + iacResultWithPlatform("VulnerableBaseImage", "sim1", "HIGH", "Dockerfile", 1), + } + _, ctx := formatFindings("/project/Dockerfile", findings) + if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { + t.Errorf("Dockerfile context should call imageRemediation, got: %q", ctx) + } + if strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") { + t.Errorf("Dockerfile context should not call codeRemediation, got: %q", ctx) + } +} + +func TestFormatFindings_DockerComposePlatformUsesImageRemediation(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{ + iacResultWithPlatform("VulnerableBaseImage", "sim1", "HIGH", "DockerCompose", 1), + } + _, ctx := formatFindings("/project/stack.yml", findings) + if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { + t.Errorf("docker-compose context should call imageRemediation, got: %q", ctx) + } +} + +func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{ + iacResultWithPlatform("OpenSecurityGroup", "sim1", "HIGH", "Terraform", 1), + } + _, ctx := formatFindings("/project/main.tf", findings) + if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") { + t.Errorf("Terraform context should call codeRemediation, got: %q", ctx) + } + if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { + t.Errorf("Terraform context should not call imageRemediation, got: %q", ctx) + } +} diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index 10048f77..c73740c1 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -6,6 +6,7 @@ import ( "strings" agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/checkmarx/ast-cli/internal/logger" "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" ) @@ -45,6 +46,7 @@ func isSupportedByKICS(filePath string) bool { func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reason, context string) { defer func() { if r := recover(); r != nil { + logger.PrintfIfVerbose("kics guardrail: recovered from panic, failing open: %v", r) blocked = false reason = "" context = "" @@ -70,6 +72,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas newResults, err := svc.scan(stagedNew) if err != nil { // Fail open: Docker unavailable, image pull failure, feature flag disabled, etc. + logger.PrintfIfVerbose("kics guardrail: scan of proposed content failed, failing open: %v", err) return false, "", "" } if len(newResults) == 0 { @@ -92,6 +95,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas origResults, err := svc.scan(stagedOrig) if err != nil { // Fail open on original scan error + logger.PrintfIfVerbose("kics guardrail: scan of original content failed, failing open: %v", err) return false, "", "" } diff --git a/internal/commands/agenthooks/guardrails/kics/scanner.go b/internal/commands/agenthooks/guardrails/kics/scanner.go index e1e99a12..c539775c 100644 --- a/internal/commands/agenthooks/guardrails/kics/scanner.go +++ b/internal/commands/agenthooks/guardrails/kics/scanner.go @@ -1,6 +1,10 @@ package kics import ( + "os" + "os/exec" + + "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" "github.com/checkmarx/ast-cli/internal/wrappers" ) @@ -27,7 +31,33 @@ func NewScannerWithFunc(f func(path string) ([]iacrealtime.IacRealtimeResult, er return &Scanner{scan: f} } +// defaultContainerEngine mirrors the "docker" default of the --engine flag on +// the manual `cx scan iac-realtime` command (internal/commands/scan.go), used +// when neither an override nor auto-detection finds a usable engine. +const defaultContainerEngine = "docker" + +// resolveContainerEngine picks the container engine name to pass to +// RunIacRealtimeScan. The guardrail is invoked as `cx hooks ` with only +// stdin JSON (no --engine flag like the manual `cx scan iac-realtime` +// command), so it resolves the engine itself: +// 1. HooksContainerEngineEnv, if set — lets a Podman/Colima-only user (or the +// agent plugin's own hook environment) override the choice explicitly. +// 2. Auto-detect via PATH lookup: try "docker" then "podman", first one found wins. +// 3. defaultContainerEngine, if neither resolves — preserves prior behavior +// and existing error messaging when no engine is installed at all. +func resolveContainerEngine() string { + if engine := os.Getenv(params.HooksContainerEngineEnv); engine != "" { + return engine + } + for _, engine := range []string{"docker", "podman"} { + if _, err := exec.LookPath(engine); err == nil { + return engine + } + } + return defaultContainerEngine +} + func (s *Scanner) runRealScan(path string) ([]iacrealtime.IacRealtimeResult, error) { svc := iacrealtime.NewIacRealtimeService(s.jwt, s.ff, iacrealtime.NewContainerManager()) - return svc.RunIacRealtimeScan(path, "", existingIgnoreFilePath()) + return svc.RunIacRealtimeScan(path, resolveContainerEngine(), existingIgnoreFilePath()) } diff --git a/internal/commands/agenthooks/guardrails/kics/scanner_test.go b/internal/commands/agenthooks/guardrails/kics/scanner_test.go new file mode 100644 index 00000000..efadcb9a --- /dev/null +++ b/internal/commands/agenthooks/guardrails/kics/scanner_test.go @@ -0,0 +1,53 @@ +//go:build !integration + +package kics + +import ( + "os" + "testing" + + "github.com/checkmarx/ast-cli/internal/params" +) + +// ── resolveContainerEngine ─────────────────────────────────────────────────── + +func TestResolveContainerEngine_EnvOverrideWins(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "podman") + if got := resolveContainerEngine(); got != "podman" { + t.Errorf("expected env override %q, got %q", "podman", got) + } +} + +func TestResolveContainerEngine_EnvOverrideArbitraryValue(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "nerdctl") + if got := resolveContainerEngine(); got != "nerdctl" { + t.Errorf("expected env override %q, got %q", "nerdctl", got) + } +} + +func TestResolveContainerEngine_FallsBackToDefaultWhenNothingResolves(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "") + // Point PATH somewhere with no docker/podman binaries so auto-detection + // finds nothing and falls back to the default. + emptyDir := t.TempDir() + t.Setenv("PATH", emptyDir) + + if got := resolveContainerEngine(); got != defaultContainerEngine { + t.Errorf("expected fallback default %q, got %q", defaultContainerEngine, got) + } +} + +func TestResolveContainerEngine_AutoDetectsFromPath(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "") + + dir := t.TempDir() + podmanPath := dir + string(os.PathSeparator) + "podman" + if err := os.WriteFile(podmanPath, []byte("#!/bin/sh\n"), 0o700); err != nil { + t.Fatalf("failed to create fake podman binary: %v", err) + } + t.Setenv("PATH", dir) + + if got := resolveContainerEngine(); got != "podman" { + t.Errorf("expected auto-detected %q, got %q", "podman", got) + } +} diff --git a/internal/params/envs.go b/internal/params/envs.go index 44134a69..42982dd7 100644 --- a/internal/params/envs.go +++ b/internal/params/envs.go @@ -24,6 +24,7 @@ const ( CodeBashingPathEnv = "CX_CODEBASHING_PATH" GroupsPathEnv = "CX_GROUPS_PATH" AgentNameEnv = "CX_AGENT_NAME" + HooksContainerEngineEnv = "CX_HOOKS_CONTAINER_ENGINE" OriginEnv = "CX_ORIGIN" ProjectsPathEnv = "CX_PROJECTS_PATH" ApplicationsPathEnv = "CX_APPLICATIONS_PATH" diff --git a/internal/services/realtimeengine/iacrealtime/config.go b/internal/services/realtimeengine/iacrealtime/config.go index 4751c198..0549b181 100644 --- a/internal/services/realtimeengine/iacrealtime/config.go +++ b/internal/services/realtimeengine/iacrealtime/config.go @@ -9,6 +9,7 @@ type IacRealtimeResult struct { ExpectedValue string `json:"ExpectedValue"` ActualValue string `json:"ActualValue"` Severity string `json:"Severity"` + Platform string `json:"Platform"` FilePath string `json:"FilePath"` Locations []realtimeengine.Location `json:"Locations"` } diff --git a/internal/services/realtimeengine/iacrealtime/mapper.go b/internal/services/realtimeengine/iacrealtime/mapper.go index 760a93dd..9d54c433 100644 --- a/internal/services/realtimeengine/iacrealtime/mapper.go +++ b/internal/services/realtimeengine/iacrealtime/mapper.go @@ -45,6 +45,7 @@ func (m *Mapper) ConvertKicsToIacResults( ExpectedValue: loc.ExpectedValue, ActualValue: loc.ActualValue, Severity: m.mapSeverity(result.Severity), + Platform: result.Platform, FilePath: filePath, SimilarityID: loc.SimilarityID, Locations: []realtimeengine.Location{