From 5c98c0c9fca8092513bbf795497d2a1441a6de17 Mon Sep 17 00:00:00 2001 From: avisab-cx <53776974+cx-avi-sabzerou@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:00:05 +0300 Subject: [PATCH 1/2] AST-164154: Fix KICS agent-hook guardrail never scanning (empty container engine) The KICS file-edit guardrail passed an empty container engine name to RunIacRealtimeScan instead of "docker", causing engine resolution to always fail and the scan to silently fail open on every file edit. Now resolves the engine via CX_HOOKS_CONTAINER_ENGINE override, then PATH auto-detection (docker/podman), falling back to "docker". Also logs the swallowed scan errors via --debug so a future regression here is diagnosable instead of silently invisible. Co-Authored-By: Claude Sonnet 5 --- .../agenthooks/guardrails/kics/kics.go | 4 ++ .../agenthooks/guardrails/kics/scanner.go | 32 ++++++++++- .../guardrails/kics/scanner_test.go | 53 +++++++++++++++++++ internal/params/envs.go | 1 + 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 internal/commands/agenthooks/guardrails/kics/scanner_test.go diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index 10048f77f..c73740c11 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 e1e99a12d..c539775c7 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 000000000..efadcb9a2 --- /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 44134a694..42982dd7c 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" From 7dcb8ee7c85c2e6ee85498038ca5f9f3351a9fba Mon Sep 17 00:00:00 2001 From: avisab-cx <53776974+cx-avi-sabzerou@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:59:07 +0300 Subject: [PATCH 2/2] Fix KICS guardrail to route Docker image findings to imageRemediation Dockerfile and docker-compose findings were sent through codeRemediation like any other IaC misconfiguration, but they need imageRemediation for base-image CVEs/hardening. Route by KICS's own platform field on the finding (Dockerfile/DockerCompose), falling back to filename heuristics only when platform metadata is unavailable. Co-Authored-By: Claude Sonnet 5 --- .../agenthooks/guardrails/kics/delta.go | 85 +++++++++++++++--- .../agenthooks/guardrails/kics/delta_test.go | 89 +++++++++++++++++++ .../realtimeengine/iacrealtime/config.go | 1 + .../realtimeengine/iacrealtime/mapper.go | 1 + 4 files changed, 162 insertions(+), 14 deletions(-) diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 9503b9498..89adeb87f 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 09f6c476f..4b7a99d6c 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/services/realtimeengine/iacrealtime/config.go b/internal/services/realtimeengine/iacrealtime/config.go index 4751c1982..0549b1810 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 760a93ddf..9d54c4338 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{