Skip to content

Commit 1ff2a6e

Browse files
Squashed commit of the following:
commit cf88a5f Author: Anurag Dalke <anurag.dalke@checkmarx.com> Date: Wed Aug 5 18:49:02 2026 +0530 AST-164154: Fix KICS agent-hook guardrail never scanning (empty container engine)- #1528 #1528 (#1531) * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix lint issue. --------- Co-authored-by: avisab-cx <53776974+cx-avi-sabzerou@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d10a20b commit 1ff2a6e

8 files changed

Lines changed: 254 additions & 15 deletions

File tree

‎internal/commands/agenthooks/guardrails/kics/delta.go‎

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package kics
22

33
import (
44
"fmt"
5+
"path/filepath"
56
"strings"
67

78
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime"
@@ -72,6 +73,43 @@ func permissionDecisionReason(filePath, summary string) string {
7273
)
7374
}
7475

76+
// dockerImagePlatforms are the KICS "platform" values (result.Platform, sourced from
77+
// KICS query metadata) whose findings concern container images rather than generic
78+
// IaC misconfigurations. These line up with the fileType enum accepted by the
79+
// imageRemediation MCP tool (Dockerfile, DockerCompose).
80+
var dockerImagePlatforms = map[string]bool{
81+
"dockerfile": true,
82+
"dockercompose": true,
83+
"docker compose": true,
84+
}
85+
86+
// isDockerImageFinding reports whether a finding's KICS platform identifies it as a
87+
// container image issue (Dockerfile/docker-compose) rather than generic IaC. Falls
88+
// back to filename heuristics only when platform is unavailable (e.g. older cached
89+
// results), since platform is scanner-reported ground truth and filenames can vary.
90+
func isDockerImageFinding(filePath string, findings []iacrealtime.IacRealtimeResult) bool {
91+
for i := range findings {
92+
if findings[i].Platform != "" {
93+
return dockerImagePlatforms[strings.ToLower(findings[i].Platform)]
94+
}
95+
}
96+
return isDockerImageFileByName(filePath)
97+
}
98+
99+
// isDockerImageFileByName is a filename-based fallback for when KICS platform metadata
100+
// isn't available. Mirrors the basename conventions in params.KicsBaseFilters plus the
101+
// docker-compose/compose naming convention (not in KicsBaseFilters since compose files
102+
// match on the generic .yml/.yaml extensions).
103+
func isDockerImageFileByName(filePath string) bool {
104+
base := strings.ToLower(filepath.Base(filePath))
105+
if base == "dockerfile" || strings.HasSuffix(base, ".dockerfile") {
106+
return true
107+
}
108+
name := strings.TrimSuffix(strings.TrimSuffix(base, ".yaml"), ".yml")
109+
return name == "docker-compose" || strings.HasPrefix(name, "docker-compose.") ||
110+
name == "compose" || strings.HasPrefix(name, "compose.")
111+
}
112+
75113
// additionalContext is injected into the agent's context window to drive remediation.
76114
// KICS is a deterministic IaC rule engine: unlike ASCA, its findings are not caused by
77115
// 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
94132
"tool or shell command.\n"+
95133
"Fix every finding below, then retry the write:\n"+
96134
"%s"+
97-
"For each finding, call the mcp__Checkmarx__codeRemediation tool with:\n"+
98-
" {\n"+
99-
" \"type\": \"iac\",\n"+
100-
" \"metadata\": {\n"+
101-
" \"title\": \"[Title from finding]\",\n"+
102-
" \"description\": \"[Description from finding]\",\n"+
103-
" \"remediationAdvice\": \"[how to harden this configuration]\"\n"+
104-
" }\n"+
105-
" }\n"+
106-
"Apply the remediation guidance the tool returns, then retry the write. If a fix "+
107-
"genuinely requires resources outside this file (for example a separate KMS key or "+
108-
"a centrally-managed policy), add them as part of your change rather than skipping "+
109-
"the finding.",
110-
filePath, findingList.String(),
135+
"%s",
136+
filePath, findingList.String(), remediationInstructions(filePath, findings),
111137
)
112138
}
139+
140+
// remediationInstructions returns the tool-call guidance for the finding's file type.
141+
// Dockerfile/docker-compose findings are about container images, so they must go
142+
// through imageRemediation (base image CVEs, safer tags, hardening). All other
143+
// KICS-supported files (Terraform, Kubernetes manifests, CloudFormation, etc.) are
144+
// generic IaC misconfigurations and go through codeRemediation.
145+
func remediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string {
146+
if isDockerImageFinding(filePath, findings) {
147+
return "For each finding, call the mcp__Checkmarx__imageRemediation tool with:\n" +
148+
" {\n" +
149+
" \"imageName\": \"[image name from the finding/file, without the tag]\",\n" +
150+
" \"imageTag\": \"[image tag from the finding/file, e.g. latest]\",\n" +
151+
" \"fileType\": \"[Dockerfile or DockerCompose, matching this file]\"\n" +
152+
" }\n" +
153+
"Apply the remediation guidance the tool returns (safer base image, pinned digest, " +
154+
"hardening steps), then retry the write."
155+
}
156+
return "For each finding, call the mcp__Checkmarx__codeRemediation tool with:\n" +
157+
" {\n" +
158+
" \"type\": \"iac\",\n" +
159+
" \"metadata\": {\n" +
160+
" \"title\": \"[Title from finding]\",\n" +
161+
" \"description\": \"[Description from finding]\",\n" +
162+
" \"remediationAdvice\": \"[how to harden this configuration]\"\n" +
163+
" }\n" +
164+
" }\n" +
165+
"Apply the remediation guidance the tool returns, then retry the write. If a fix " +
166+
"genuinely requires resources outside this file (for example a separate KMS key or " +
167+
"a centrally-managed policy), add them as part of your change rather than skipping " +
168+
"the finding."
169+
}

‎internal/commands/agenthooks/guardrails/kics/delta_test.go‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ func iacResult(title, similarityID, severity string, line int) iacrealtime.IacRe
2020
}
2121
}
2222

23+
func iacResultWithPlatform(title, platform string) iacrealtime.IacRealtimeResult {
24+
r := iacResult(title, "sim1", "HIGH", 1)
25+
r.Platform = platform
26+
return r
27+
}
28+
2329
// ── NewFindings ───────────────────────────────────────────────────────────────
2430

2531
func TestNewFindings_NilOriginalReturnsAll(t *testing.T) {
@@ -123,3 +129,86 @@ func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) {
123129
t.Errorf("context should warn against bypass, got: %q", ctx)
124130
}
125131
}
132+
133+
// ── isDockerImageFinding / remediation tool routing ────────────────────────────
134+
135+
func TestIsDockerImageFinding_ByPlatform(t *testing.T) {
136+
cases := []struct {
137+
platform string
138+
want bool
139+
}{
140+
{"Dockerfile", true},
141+
{"DockerCompose", true},
142+
{"Docker Compose", true},
143+
{"dockerfile", true},
144+
{"Terraform", false},
145+
{"Kubernetes", false},
146+
{"CloudFormation", false},
147+
{"Ansible", false},
148+
}
149+
for _, c := range cases {
150+
findings := []iacrealtime.IacRealtimeResult{
151+
iacResultWithPlatform("SomeFinding", c.platform),
152+
}
153+
// Filename deliberately contradicts platform to prove platform wins.
154+
if got := isDockerImageFinding("/project/values.yaml", findings); got != c.want {
155+
t.Errorf("isDockerImageFinding with platform %q = %v, want %v", c.platform, got, c.want)
156+
}
157+
}
158+
}
159+
160+
func TestIsDockerImageFinding_FallsBackToFilenameWhenPlatformEmpty(t *testing.T) {
161+
cases := map[string]bool{
162+
"/project/Dockerfile": true,
163+
"/project/api.dockerfile": true,
164+
"/project/docker-compose.yml": true,
165+
"/project/docker-compose.yaml": true,
166+
"/project/docker-compose.prod.yml": true,
167+
"/project/compose.yaml": true,
168+
"/project/main.tf": false,
169+
"/project/deployment.yaml": false,
170+
"/project/values.yaml": false,
171+
}
172+
for path, want := range cases {
173+
findings := []iacrealtime.IacRealtimeResult{iacResult("SomeFinding", "sim1", "HIGH", 1)}
174+
if got := isDockerImageFinding(path, findings); got != want {
175+
t.Errorf("isDockerImageFinding(%q) with no platform = %v, want %v", path, got, want)
176+
}
177+
}
178+
}
179+
180+
func TestFormatFindings_DockerfilePlatformUsesImageRemediation(t *testing.T) {
181+
findings := []iacrealtime.IacRealtimeResult{
182+
iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"),
183+
}
184+
_, ctx := formatFindings("/project/Dockerfile", findings)
185+
if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
186+
t.Errorf("Dockerfile context should call imageRemediation, got: %q", ctx)
187+
}
188+
if strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
189+
t.Errorf("Dockerfile context should not call codeRemediation, got: %q", ctx)
190+
}
191+
}
192+
193+
func TestFormatFindings_DockerComposePlatformUsesImageRemediation(t *testing.T) {
194+
findings := []iacrealtime.IacRealtimeResult{
195+
iacResultWithPlatform("VulnerableBaseImage", "DockerCompose"),
196+
}
197+
_, ctx := formatFindings("/project/stack.yml", findings)
198+
if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
199+
t.Errorf("docker-compose context should call imageRemediation, got: %q", ctx)
200+
}
201+
}
202+
203+
func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) {
204+
findings := []iacrealtime.IacRealtimeResult{
205+
iacResultWithPlatform("OpenSecurityGroup", "Terraform"),
206+
}
207+
_, ctx := formatFindings("/project/main.tf", findings)
208+
if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
209+
t.Errorf("Terraform context should call codeRemediation, got: %q", ctx)
210+
}
211+
if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
212+
t.Errorf("Terraform context should not call imageRemediation, got: %q", ctx)
213+
}
214+
}

‎internal/commands/agenthooks/guardrails/kics/kics.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77

88
agenthooks "github.com/Checkmarx/ast-cx-hooks"
9+
"github.com/checkmarx/ast-cli/internal/logger"
910
"github.com/checkmarx/ast-cli/internal/params"
1011
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore"
1112
)
@@ -45,6 +46,7 @@ func isSupportedByKICS(filePath string) bool {
4546
func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reason, context string) {
4647
defer func() {
4748
if r := recover(); r != nil {
49+
logger.PrintfIfVerbose("kics guardrail: recovered from panic, failing open: %v", r)
4850
blocked = false
4951
reason = ""
5052
context = ""
@@ -70,6 +72,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas
7072
newResults, err := svc.scan(stagedNew)
7173
if err != nil {
7274
// Fail open: Docker unavailable, image pull failure, feature flag disabled, etc.
75+
logger.PrintfIfVerbose("kics guardrail: scan of proposed content failed, failing open: %v", err)
7376
return false, "", ""
7477
}
7578
if len(newResults) == 0 {
@@ -92,6 +95,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas
9295
origResults, err := svc.scan(stagedOrig)
9396
if err != nil {
9497
// Fail open on original scan error
98+
logger.PrintfIfVerbose("kics guardrail: scan of original content failed, failing open: %v", err)
9599
return false, "", ""
96100
}
97101

‎internal/commands/agenthooks/guardrails/kics/scanner.go‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package kics
22

33
import (
4+
"os"
5+
"os/exec"
6+
7+
"github.com/checkmarx/ast-cli/internal/params"
48
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime"
59
"github.com/checkmarx/ast-cli/internal/wrappers"
610
)
@@ -27,7 +31,33 @@ func NewScannerWithFunc(f func(path string) ([]iacrealtime.IacRealtimeResult, er
2731
return &Scanner{scan: f}
2832
}
2933

34+
// defaultContainerEngine mirrors the "docker" default of the --engine flag on
35+
// the manual `cx scan iac-realtime` command (internal/commands/scan.go), used
36+
// when neither an override nor auto-detection finds a usable engine.
37+
const defaultContainerEngine = "docker"
38+
39+
// resolveContainerEngine picks the container engine name to pass to
40+
// RunIacRealtimeScan. The guardrail is invoked as `cx hooks <route>` with only
41+
// stdin JSON (no --engine flag like the manual `cx scan iac-realtime`
42+
// command), so it resolves the engine itself:
43+
// 1. HooksContainerEngineEnv, if set — lets a Podman/Colima-only user (or the
44+
// agent plugin's own hook environment) override the choice explicitly.
45+
// 2. Auto-detect via PATH lookup: try "docker" then "podman", first one found wins.
46+
// 3. defaultContainerEngine, if neither resolves — preserves prior behavior
47+
// and existing error messaging when no engine is installed at all.
48+
func resolveContainerEngine() string {
49+
if engine := os.Getenv(params.HooksContainerEngineEnv); engine != "" {
50+
return engine
51+
}
52+
for _, engine := range []string{"docker", "podman"} {
53+
if _, err := exec.LookPath(engine); err == nil {
54+
return engine
55+
}
56+
}
57+
return defaultContainerEngine
58+
}
59+
3060
func (s *Scanner) runRealScan(path string) ([]iacrealtime.IacRealtimeResult, error) {
3161
svc := iacrealtime.NewIacRealtimeService(s.jwt, s.ff, iacrealtime.NewContainerManager())
32-
return svc.RunIacRealtimeScan(path, "", existingIgnoreFilePath())
62+
return svc.RunIacRealtimeScan(path, resolveContainerEngine(), existingIgnoreFilePath())
3363
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//go:build !integration
2+
3+
package kics
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
10+
"github.com/checkmarx/ast-cli/internal/params"
11+
)
12+
13+
const enginePodman = "podman"
14+
15+
// ── resolveContainerEngine ───────────────────────────────────────────────────
16+
17+
func TestResolveContainerEngine_EnvOverrideWins(t *testing.T) {
18+
t.Setenv(params.HooksContainerEngineEnv, enginePodman)
19+
if got := resolveContainerEngine(); got != enginePodman {
20+
t.Errorf("expected env override %q, got %q", enginePodman, got)
21+
}
22+
}
23+
24+
func TestResolveContainerEngine_EnvOverrideArbitraryValue(t *testing.T) {
25+
t.Setenv(params.HooksContainerEngineEnv, "nerdctl")
26+
if got := resolveContainerEngine(); got != "nerdctl" {
27+
t.Errorf("expected env override %q, got %q", "nerdctl", got)
28+
}
29+
}
30+
31+
func TestResolveContainerEngine_FallsBackToDefaultWhenNothingResolves(t *testing.T) {
32+
t.Setenv(params.HooksContainerEngineEnv, "")
33+
// Point PATH somewhere with no docker/podman binaries so auto-detection
34+
// finds nothing and falls back to the default.
35+
emptyDir := t.TempDir()
36+
t.Setenv("PATH", emptyDir)
37+
38+
if got := resolveContainerEngine(); got != defaultContainerEngine {
39+
t.Errorf("expected fallback default %q, got %q", defaultContainerEngine, got)
40+
}
41+
}
42+
43+
func TestResolveContainerEngine_AutoDetectsFromPath(t *testing.T) {
44+
t.Setenv(params.HooksContainerEngineEnv, "")
45+
46+
dir := t.TempDir()
47+
podmanPath := filepath.Join(dir, enginePodman)
48+
if err := os.WriteFile(podmanPath, []byte("#!/bin/sh\n"), 0o700); err != nil {
49+
t.Fatalf("failed to create fake podman binary: %v", err)
50+
}
51+
t.Setenv("PATH", dir)
52+
53+
if got := resolveContainerEngine(); got != enginePodman {
54+
t.Errorf("expected auto-detected %q, got %q", enginePodman, got)
55+
}
56+
}

‎internal/params/envs.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
CodeBashingPathEnv = "CX_CODEBASHING_PATH"
2525
GroupsPathEnv = "CX_GROUPS_PATH"
2626
AgentNameEnv = "CX_AGENT_NAME"
27+
HooksContainerEngineEnv = "CX_HOOKS_CONTAINER_ENGINE"
2728
OriginEnv = "CX_ORIGIN"
2829
ProjectsPathEnv = "CX_PROJECTS_PATH"
2930
ApplicationsPathEnv = "CX_APPLICATIONS_PATH"

‎internal/services/realtimeengine/iacrealtime/config.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type IacRealtimeResult struct {
99
ExpectedValue string `json:"ExpectedValue"`
1010
ActualValue string `json:"ActualValue"`
1111
Severity string `json:"Severity"`
12+
Platform string `json:"Platform"`
1213
FilePath string `json:"FilePath"`
1314
Locations []realtimeengine.Location `json:"Locations"`
1415
}

‎internal/services/realtimeengine/iacrealtime/mapper.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ func (m *Mapper) ConvertKicsToIacResults(
4545
ExpectedValue: loc.ExpectedValue,
4646
ActualValue: loc.ActualValue,
4747
Severity: m.mapSeverity(result.Severity),
48+
Platform: result.Platform,
4849
FilePath: filePath,
4950
SimilarityID: loc.SimilarityID,
5051
Locations: []realtimeengine.Location{

0 commit comments

Comments
 (0)