Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 71 additions & 14 deletions internal/commands/agenthooks/guardrails/kics/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import (
"fmt"
"path/filepath"
"strings"

"github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime"
Expand Down Expand Up @@ -72,6 +73,43 @@
)
}

// 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 {

Check failure on line 91 in internal/commands/agenthooks/guardrails/kics/delta.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

rangeValCopy: each iteration copies 152 bytes (consider pointers or indexing) (gocritic)
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
Expand All @@ -94,19 +132,38 @@
"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."
}
89 changes: 89 additions & 0 deletions internal/commands/agenthooks/guardrails/kics/delta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
}
}

func iacResultWithPlatform(title, similarityID, severity, platform string, line int) iacrealtime.IacRealtimeResult {

Check failure on line 23 in internal/commands/agenthooks/guardrails/kics/delta_test.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

iacResultWithPlatform - similarityID always receives "sim1" (unparam)
r := iacResult(title, similarityID, severity, line)
r.Platform = platform
return r
}

// ── NewFindings ───────────────────────────────────────────────────────────────

func TestNewFindings_NilOriginalReturnsAll(t *testing.T) {
Expand Down Expand Up @@ -123,3 +129,86 @@
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)
}
}
4 changes: 4 additions & 0 deletions internal/commands/agenthooks/guardrails/kics/kics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 = ""
Expand All @@ -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 {
Expand All @@ -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, "", ""
}

Expand Down
32 changes: 31 additions & 1 deletion internal/commands/agenthooks/guardrails/kics/scanner.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand All @@ -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 <route>` 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())
}
53 changes: 53 additions & 0 deletions internal/commands/agenthooks/guardrails/kics/scanner_test.go
Original file line number Diff line number Diff line change
@@ -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" {

Check failure on line 16 in internal/commands/agenthooks/guardrails/kics/scanner_test.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

string `podman` has 2 occurrences, make it a constant (goconst)
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"

Check failure on line 44 in internal/commands/agenthooks/guardrails/kics/scanner_test.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

preferFilepathJoin: filepath.Join(dir, "podman") should be preferred to the dir + string(os.PathSeparator) + "podman" (gocritic)
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)
}
}
1 change: 1 addition & 0 deletions internal/params/envs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions internal/services/realtimeengine/iacrealtime/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
1 change: 1 addition & 0 deletions internal/services/realtimeengine/iacrealtime/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading