diff --git a/CODEOWNERS b/CODEOWNERS index 98b61c2b..6433cf7d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,4 +3,4 @@ # Each line is a file pattern followed by one or more owners # Specify the default owners for the entire repository -* @cx-anurag-dalke @cx-anjali-deore @cx-umesh-waghode +* @Checkmarx/cx-maintainers diff --git a/go.mod b/go.mod index 820d7faa..27a6d5f8 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,9 @@ module github.com/checkmarx/ast-cli -go 1.26.5 +go 1.26.6 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.5 + github.com/Checkmarx/ast-cx-hooks v1.0.6 github.com/Checkmarx/containers-resolver v1.0.34 github.com/Checkmarx/containers-types v1.0.9 github.com/Checkmarx/gen-ai-prompts v0.0.0-20240807143411-708ceec12b63 @@ -29,9 +29,9 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.55.0 golang.org/x/sync v0.22.0 - golang.org/x/text v0.39.0 + golang.org/x/text v0.41.0 google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af gopkg.in/yaml.v3 v3.0.1 @@ -292,13 +292,13 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.56.0 // indirect + golang.org/x/mod v0.40.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/term v0.44.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.49.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect diff --git a/go.sum b/go.sum index ecc531e6..34b4cffe 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Checkmarx/ast-cx-hooks v1.0.5 h1:4Og5JeBBg3SynAErAP76oGKrjoWrlduWRgg1V9IXjWo= -github.com/Checkmarx/ast-cx-hooks v1.0.5/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= +github.com/Checkmarx/ast-cx-hooks v1.0.6 h1:8/Kcl9V0XKeY1vgTKJR6eIfXXoa4c9DgUOBuY1Ms268= +github.com/Checkmarx/ast-cx-hooks v1.0.6/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= github.com/Checkmarx/containers-images-extractor v1.0.22 h1:kJZgwk28LwJZ7Xky+kzwL+JSZOlpwrGsZQhhz4L2t6s= github.com/Checkmarx/containers-images-extractor v1.0.22/go.mod h1:HyzVb8TtTDf56hGlSakalPXtzjJ6VhTYe9fmAcOS+V8= github.com/Checkmarx/containers-resolver v1.0.34 h1:KULN8s8xb1tQtdH4yzHVdwN8GyLqtPCAkFWra10k7V0= @@ -1112,8 +1112,8 @@ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1153,8 +1153,8 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= +golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1200,8 +1200,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1311,13 +1311,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1328,8 +1328,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1390,8 +1390,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/commands/agenthooks/cursorplugin/plugin.go b/internal/commands/agenthooks/cursorplugin/plugin.go new file mode 100644 index 00000000..077e6530 --- /dev/null +++ b/internal/commands/agenthooks/cursorplugin/plugin.go @@ -0,0 +1,46 @@ +// Package cursorplugin holds Cursor-plugin-specific fragments for agent-hook remediation +// guidance (MCP tool names, PowerShell stop-parsing suppress commands). +package cursorplugin + +import ( + "fmt" + "runtime" + "strings" +) + +// MCPServerID is how Cursor names the Checkmarx MCP when cx-devassist is installed as a plugin +// (plugin id "cx-devassist" + mcp.json server key "Checkmarx"). +const MCPServerID = "plugin-cx-devassist-Checkmarx" + +// MCPTool returns the fully-qualified Cursor MCP tool name for a remediation tool. +func MCPTool(tool string) string { + return "mcp__" + MCPServerID + "__" + tool +} + +const goosWindows = "windows" + +// IgnoreVulnerabilityCommand renders `cx ignore-vulnerability` for Cursor agents. +// On Windows, uses PowerShell --% with the JSON wrapped in double quotes and inner quotes +// backslash-escaped — the only form that survives PowerShell's native argv parsing. +func IgnoreVulnerabilityCommand(cxBinary, scanType string, data []byte, ignoreFlag, provenance string) string { + if runtime.GOOS == goosWindows { + escaped := escapeJSONForStopParsing(string(data)) + // escaped is already quote-escaped for PowerShell's double-quoted string rules; + // %q would re-escape it using Go's own rules (e.g. doubling backslashes) and corrupt it. + return fmt.Sprintf(` & %q --%% ignore-vulnerability --scan-type %s --data "%s"%s%s`, //nolint:gocritic + cxBinary, scanType, escaped, ignoreFlag, provenance) + } + escaped := escapeJSONForPOSIX(string(data)) + // escaped is already quote-escaped for the POSIX shell's double-quoted string rules; + // %q would re-escape it using Go's own rules (e.g. doubling backslashes) and corrupt it. + return fmt.Sprintf(` %s ignore-vulnerability --scan-type %s --data "%s"%s%s`, //nolint:gocritic + cxBinary, scanType, escaped, ignoreFlag, provenance) +} + +func escapeJSONForStopParsing(data string) string { + return strings.ReplaceAll(data, `"`, `\"`) +} + +func escapeJSONForPOSIX(data string) string { + return strings.ReplaceAll(data, `"`, `\"`) +} diff --git a/internal/commands/agenthooks/cursorplugin/plugin_test.go b/internal/commands/agenthooks/cursorplugin/plugin_test.go new file mode 100644 index 00000000..5a935afd --- /dev/null +++ b/internal/commands/agenthooks/cursorplugin/plugin_test.go @@ -0,0 +1,57 @@ +package cursorplugin + +import ( + "runtime" + "strings" + "testing" +) + +func TestMCPTool(t *testing.T) { + got := MCPTool("codeRemediation") + want := "mcp__plugin-cx-devassist-Checkmarx__codeRemediation" + if got != want { + t.Errorf("MCPTool() = %q, want %q", got, want) + } +} + +func TestIgnoreVulnerabilityCommand_WindowsUsesStopParsing(t *testing.T) { + if runtime.GOOS != goosWindows { + t.Skip("windows-only") + } + data := []byte(`{"FileName":"Demo.java","Line":5,"RuleID":1027}`) + cmd := IgnoreVulnerabilityCommand(`C:\cx\cx.exe`, "asca", data, ` --ignored-file-path "c:/proj/.checkmarx/ignored.json"`, "") + if !strings.Contains(cmd, `--% ignore-vulnerability`) { + t.Errorf("expected --%% stop-parsing, got %q", cmd) + } + want := `--data "{\"FileName\":\"Demo.java\",\"Line\":5,\"RuleID\":1027}"` + if !strings.Contains(cmd, want) { + t.Errorf("expected quoted backslash-escaped JSON, got %q", cmd) + } +} + +func TestIgnoreVulnerabilityCommand_UnixEscapesJSON(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("unix-only") + } + data := []byte(`{"FileName":"Demo.java"}`) + cmd := IgnoreVulnerabilityCommand("cx", "asca", data, "", "") + if !strings.Contains(cmd, `\"FileName\"`) { + t.Errorf("expected backslash-escaped JSON on unix, got %q", cmd) + } +} + +func TestEscapeJSONForPOSIX_EscapesEmbeddedQuotes(t *testing.T) { + got := escapeJSONForPOSIX(`{"FileName":"Demo.java"}`) + want := `{\"FileName\":\"Demo.java\"}` + if got != want { + t.Errorf("escapeJSONForPOSIX() = %q, want %q", got, want) + } +} + +func TestEscapeJSONForPOSIX_NoQuotesUnchanged(t *testing.T) { + got := escapeJSONForPOSIX("no quotes here") + want := "no quotes here" + if got != want { + t.Errorf("escapeJSONForPOSIX() = %q, want %q", got, want) + } +} diff --git a/internal/commands/agenthooks/cx/hooks_test.go b/internal/commands/agenthooks/cx/hooks_test.go index 90d4f48d..e11f2da7 100644 --- a/internal/commands/agenthooks/cx/hooks_test.go +++ b/internal/commands/agenthooks/cx/hooks_test.go @@ -332,7 +332,7 @@ func TestCxBeforeFileEdit_TotalFileSize_Rejects(t *testing.T) { func TestCxBeforeFileEdit_KICSFinding_RejectsWithContext(t *testing.T) { resetHookGlobals(t) - kicsScanner = kics.NewScannerWithFunc(func(string) ([]iacrealtime.IacRealtimeResult, error) { + kicsScanner = kics.NewScannerWithFunc(func(string, string) ([]iacrealtime.IacRealtimeResult, error) { return []iacrealtime.IacRealtimeResult{{ Title: "Privileged Container", SimilarityID: "sim123", diff --git a/internal/commands/agenthooks/cx/install.go b/internal/commands/agenthooks/cx/install.go index 33f6caea..a47e829a 100644 --- a/internal/commands/agenthooks/cx/install.go +++ b/internal/commands/agenthooks/cx/install.go @@ -49,8 +49,9 @@ var Agents = []Agent{ {"cursor-stop", "Cursor agent finished"}, {"cursor-before-shell", "Gate Cursor shell execution"}, {"cursor-before-mcp", "Gate Cursor MCP execution"}, + {"cursor-before-file-write", "Gate Cursor file write (preToolUse)"}, {"cursor-before-file-read", "Gate Cursor file read"}, - {"cursor-after-file-edit", "React to Cursor file edit"}, + {"cursor-after-file-edit", "React to Cursor file edit (postToolUse)"}, {"cursor-before-submit-prompt", "Gate Cursor prompt"}, }, }, diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 4c04c10f..4e095f3c 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -211,7 +211,7 @@ func TestStageForScan_DotDotOriginalPath_ReturnsError(t *testing.T) { } func TestStageForScan_FileMode(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { t.Skip("Unix permission bits (0600) are not enforced on Windows; validated on Linux/macOS CI") } staged, cleanup, err := stageForScan("/tmp/secret.py", "secret", "s1", agenthooks.AgentID("test")) @@ -340,6 +340,65 @@ func TestAdditionalContext_EmptyFindings_StillContainsRemediationInstruction(t * } } +func TestCursorAdditionalContext_UsesPluginMCPTool(t *testing.T) { + ctx := cursorAdditionalContext("main.py", "cx", nil, "", "") + if !strings.Contains(ctx, "mcp__plugin-cx-devassist-Checkmarx__codeRemediation") { + t.Errorf("expected plugin-prefixed MCP tool, got %q", ctx) + } +} + +func TestCursorAdditionalContext_CursorSuppressCommandUsesStopParsingOnWindows(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "Demo.java", Line: 5, RuleID: 1027}} + ctx := cursorAdditionalContext("Demo.java", "cx", findings, "", "sess-1") + if runtime.GOOS == goosWindows { + if !strings.Contains(ctx, `--% ignore-vulnerability`) { + t.Errorf("expected PowerShell stop-parsing on windows, got %q", ctx) + } + if strings.Contains(ctx, `""FileName""`) { + t.Errorf("must not use doubled-quote escaping, got %q", ctx) + } + if !strings.Contains(ctx, `\"FileName\"`) { + t.Errorf("expected backslash-escaped JSON in stop-parsing form, got %q", ctx) + } + } +} + +func TestCursorEscapeJSON_MatchesTheShellCursorActuallyRunsOn(t *testing.T) { + got := cursorEscapeJSON(`{"FileName":"Demo.java"}`) + if runtime.GOOS == goosWindows { + // PowerShell double-quoted strings escape an embedded `"` by doubling it; a + // backslash is not a quote-escape there, so `\"` would corrupt the command. + want := `{""FileName"":""Demo.java""}` + if got != want { + t.Errorf("expected doubled-quote escaping on windows (PowerShell), got %q", got) + } + } else { + want := `{\"FileName\":\"Demo.java\"}` + if got != want { + t.Errorf("expected backslash-escaped quotes on unix (bash), got %q", got) + } + } +} + +func TestFormatFindings_RoutesCursorQuoting(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "a.py", Line: 1, RuleID: 1}} + _, ctx := formatFindings("a.py", findings, "", "Cursor", "sess-1") + if runtime.GOOS == goosWindows { + if !strings.Contains(ctx, `--% ignore-vulnerability`) { + t.Fatalf("cursor agent on windows should get stop-parsing suppress command, got %q", ctx) + } + } else if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data "`) { + t.Fatalf("cursor agent on unix should get double-quoted suppress command, got %q", ctx) + } + if !strings.Contains(ctx, "mcp__plugin-cx-devassist-Checkmarx__codeRemediation") { + t.Fatalf("cursor agent should get plugin MCP tool name, got %q", ctx) + } + _, ctx = formatFindings("a.py", findings, "", "Claude", "sess-1") + if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data '`) { + t.Fatalf("claude agent should get single-quoted suppress command, got %q", ctx) + } +} + func TestAdditionalContext_PinsIgnoredFilePathToWorkDir(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, diff --git a/internal/commands/agenthooks/guardrails/asca/content_extra_test.go b/internal/commands/agenthooks/guardrails/asca/content_extra_test.go new file mode 100644 index 00000000..e693c7c8 --- /dev/null +++ b/internal/commands/agenthooks/guardrails/asca/content_extra_test.go @@ -0,0 +1,28 @@ +//go:build !integration + +package asca + +import "testing" + +const wantNormalizedLines = "line1\nline2\nline3" + +func TestNormLF_CRLFNormalized(t *testing.T) { + got := normLF("line1\r\nline2\r\nline3") + if got != wantNormalizedLines { + t.Errorf("normLF() = %q, want %q", got, wantNormalizedLines) + } +} + +func TestNormLF_BareCRNormalized(t *testing.T) { + got := normLF("line1\rline2\rline3") + if got != wantNormalizedLines { + t.Errorf("normLF() = %q, want %q", got, wantNormalizedLines) + } +} + +func TestNormLF_AlreadyLFUnchanged(t *testing.T) { + got := normLF("line1\nline2\nline3") + if got != wantNormalizedLines { + t.Errorf("normLF() = %q, want %q", got, wantNormalizedLines) + } +} diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index afcf92ab..45d9adca 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -4,12 +4,27 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/cursorplugin" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/wrappers/grpcs" ) +// agentCursor identifies Cursor for the shell-quoting branch below. Cursor's CLI +// reformats single-quoted commands into double-quoted ones (notably on Windows +// PowerShell), so its suppression commands need double-quoted JSON with the +// embedded quotes escaped for the shell actually in play (see cursorEscapeJSON) — +// otherwise the reformatted command corrupts the JSON payload or drops +// --ignored-file-path, silently sending the suppression to the wrong file. +const agentCursor = "Cursor" + +// goosWindows is runtime.GOOS's value on Windows, factored out because the shell-quoting +// checks below (and their tests) compare against it repeatedly. +const goosWindows = "windows" + // findingKey is the deduplication tuple used for delta detection. // Mirrors the cx-devassist plugin's matching logic. type findingKey struct { @@ -71,7 +86,13 @@ func formatFindings(filePath string, findings []grpcs.ScanDetail, workDir, agent if err == nil { cxBinary = cxExe } - return permissionDecisionReason(filePath, summary), additionalContext(filePath, cxBinary, findings, workDir, agent, sessionID) + reason = permissionDecisionReason(filePath, summary) + if agent == agentCursor { + context = cursorAdditionalContext(filePath, cxBinary, findings, workDir, sessionID) + } else { + context = additionalContext(filePath, cxBinary, findings, workDir, agent, sessionID) + } + return reason, context } // ignoredFilePathFlag returns the " --ignored-file-path ''" fragment that pins @@ -88,6 +109,34 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// cursorIgnoredFilePathFlag is the Cursor-specific variant of ignoredFilePathFlag. It uses +// double quotes and converts backslashes to forward slashes so the flag survives Windows +// PowerShell and cmd.exe without the agent needing to re-quote it. (Cursor agents on Windows +// tend to reformat single-quoted shell commands into double-quoted form and drop flags that +// have complex quoting, causing the ignore entry to land in the wrong directory.) +func cursorIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(" --ignored-file-path %q", p) +} + +// cursorEscapeJSON escapes the embedded `"` in a JSON payload so it survives being placed +// inside a double-quoted argument on the shell that actually runs the Cursor agent's command: +// PowerShell on Windows, bash/zsh elsewhere. This runs on the developer's own machine (inside +// the cx process), so runtime.GOOS reflects that shell choice directly. The two shells disagree +// on how to escape an embedded double quote — bash accepts a backslash-escaped `\"`, but +// PowerShell's double-quoted strings do NOT treat `\` as an escape character at all: `\"` ends +// the string early (backslash is literal, then the quote closes it), corrupting everything +// after the first embedded quote. PowerShell requires the quote to be doubled (`""`) instead. +func cursorEscapeJSON(data string) string { + if runtime.GOOS == goosWindows { + return strings.ReplaceAll(data, `"`, `""`) + } + return strings.ReplaceAll(data, `"`, `\"`) +} + // optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the // child `cx ignore-vulnerability` process via --optional-flags, which reads them through // utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. @@ -114,8 +163,8 @@ func permissionDecisionReason(filePath, summary string) string { // additionalContext is injected into the agent's context window to drive remediation. // Contains all action instructions — not shown directly to the user. +// Used for Claude, Copilot, and other non-Cursor agents. func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { - ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, f := range findings { @@ -124,6 +173,7 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w Line: f.Line, RuleID: f.RuleID, }) + ignoreFlag := ignoredFilePathFlag(workDir) fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) } return fmt.Sprintf( @@ -149,3 +199,55 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w filePath, suppressCmds.String(), ) } + +// cursorAdditionalContext is remediation guidance for Cursor only. Uses the plugin-prefixed MCP +// tool name and PowerShell --% stop-parsing for suppress commands on Windows. +func cursorAdditionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, sessionID string) string { + provenance := optionalFlagsFragment(agentCursor, sessionID) + var suppressCmds strings.Builder + for i := range findings { + f := &findings[i] + data, _ := json.Marshal(grpcs.AscaIgnoreFinding{ + FileName: f.FileName, + Line: f.Line, + RuleID: f.RuleID, + }) + ignoreFlag := cursorIgnoredFilePathFlag(workDir) + suppressCmds.WriteString(cursorplugin.IgnoreVulnerabilityCommand(cxBinary, "asca", data, ignoreFlag, provenance)) + suppressCmds.WriteString("\n") + } + tool := cursorplugin.MCPTool("codeRemediation") + return fmt.Sprintf( + "ASCA detected vulnerabilities in %s. "+ + "Do not bypass the scan by writing the same content through another tool or shell command. "+ + "ANALYZE each finding to determine if it is a real vulnerability or a false positive "+ + "caused by ASCA's single-file scope (it cannot see imported modules or helper files). "+ + "Follow the cx-hook-deny.mdc rule for this deny. "+ + "ASK THE USER FIRST, for every real finding, before taking any action: \"A security "+ + "vulnerability was detected. Would you like to remediate it (apply an MCP-driven code fix) "+ + "or suppress it (mark as a confirmed false positive and unblock the write)?\" and wait for "+ + "their answer. Do not decide this yourself — an intentionally-inserted vulnerability (e.g. "+ + "in a lab/demo/training file the user asked for on purpose) is NOT the same as a confirmed "+ + "false positive: suppress only on the user's explicit instruction, never because the "+ + "request seems intentional. "+ + "Apply the cx-devassist-asca.mdc rule: for each finding the user asks you to remediate, "+ + "invoke the cx-devassist:cx-devassist-asca skill exactly as written — do not skip, "+ + "abbreviate, or reimplement its steps inline. The findings are already in context so it "+ + "will skip the scan and go directly to MCP-driven remediation; the skill also handles MCP "+ + "unavailability and self-recovery. Always show its Step 5 Remediation Summary to the user "+ + "verbatim when done. "+ + "If that skill is not available in this session, call %s directly:\n"+ + " {\n"+ + " \"language\": \"[auto-detected programming language]\",\n"+ + " \"metadata\": {\n"+ + " \"ruleId\": \"[rule_name from scan]\",\n"+ + " \"description\": \"[description from scan]\",\n"+ + " \"remediationAdvice\": \"[remediationAdvise from scan]\"\n"+ + " },\n"+ + " \"type\": \"sast\"\n"+ + " }\n"+ + "Use the remediation guidance returned by the tool to fix the vulnerability, then retry the write. "+ + "If the user chooses to suppress a finding, run the corresponding command below, then retry the write:\n%s", + filePath, tool, suppressCmds.String(), + ) +} diff --git a/internal/commands/agenthooks/guardrails/asca/stage_extra_test.go b/internal/commands/agenthooks/guardrails/asca/stage_extra_test.go new file mode 100644 index 00000000..c53928c2 --- /dev/null +++ b/internal/commands/agenthooks/guardrails/asca/stage_extra_test.go @@ -0,0 +1,54 @@ +//go:build !integration + +package asca + +import ( + "testing" + "unicode/utf8" +) + +func TestAsciiSafe_AllASCIIUnchanged(t *testing.T) { + got := asciiSafe("package main // clean ascii comment") + want := "package main // clean ascii comment" + if got != want { + t.Errorf("asciiSafe() = %q, want %q", got, want) + } +} + +func TestAsciiSafe_NonASCIIReplacedWithSpace(t *testing.T) { + in := "// café comment" + got := asciiSafe(in) + want := "// caf comment" + if got != want { + t.Errorf("asciiSafe() = %q, want %q", got, want) + } + if !utf8.ValidString(got) { + t.Errorf("asciiSafe() produced invalid utf8: %q", got) + } + for _, r := range got { + if r > maxASCIICodePoint { + t.Errorf("asciiSafe() left a non-ASCII rune %q in %q", r, got) + } + } +} + +func TestAsciiSafe_PreservesLineStructure(t *testing.T) { + in := "line1\nline2 with é\nline3" + got := asciiSafe(in) + wantLines := 3 + lines := 1 + for _, r := range got { + if r == '\n' { + lines++ + } + } + if lines != wantLines { + t.Errorf("asciiSafe() changed line count: got %d lines, want %d", lines, wantLines) + } +} + +func TestAsciiSafe_EmptyString(t *testing.T) { + if got := asciiSafe(""); got != "" { + t.Errorf("asciiSafe(\"\") = %q, want empty", got) + } +} diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 05626847..883d430a 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" + agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/cursorplugin" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" ) @@ -60,9 +62,17 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult) } // formatFindings builds the two verdict fields delivered to the agent. -func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult) (reason, context string) { +// Cursor receives cursorAdditionalContext (folded into agent_message); other agents +// receive the original additionalContext (e.g. Claude additionalContext). +func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) (reason, context string) { summary := findingsSummary(filePath, findings) - return permissionDecisionReason(filePath, summary), additionalContext(filePath, findings) + reason = permissionDecisionReason(filePath, summary) + if agent == agenthooks.AgentCursor { + context = cursorAdditionalContext(filePath, findings) + } else { + context = additionalContext(filePath, findings) + } + return reason, context } // permissionDecisionReason is the human-readable deny message shown to the user. @@ -114,6 +124,7 @@ func isDockerImageFileByName(filePath string) bool { // 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 // false positives. Every new finding must be fixed. +// Used for Claude, Copilot, and other non-Cursor agents. func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string { var findingList strings.Builder for _, f := range findings { @@ -167,3 +178,55 @@ func remediationInstructions(filePath string, findings []iacrealtime.IacRealtime "a centrally-managed policy), add them as part of your change rather than skipping " + "the finding." } + +func cursorRemediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string { + if isDockerImageFinding(filePath, findings) { + return fmt.Sprintf("For each finding, call the %s 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.", cursorplugin.MCPTool("imageRemediation")) + } + return fmt.Sprintf("For each finding, call the %s 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.", cursorplugin.MCPTool("codeRemediation")) +} + +// cursorAdditionalContext is remediation guidance for Cursor only. Cursor has no +// additionalContext field on preToolUse — ast-cx-hooks folds this into agent_message. +func cursorAdditionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string { + var findingList strings.Builder + for i := range findings { + f := &findings[i] + line := 0 + if len(f.Locations) > 0 { + line = f.Locations[0].Line + } + fmt.Fprintf(&findingList, " - line %d [%s] %s: %s\n", + line, f.Severity, f.Title, f.Description) + } + return fmt.Sprintf( + "KICS IaC findings in %s — apply the cx-hook-deny.mdc rule for this deny, and the "+ + "cx-devassist-kics.mdc rule exactly as written: do not "+ + "skip, abbreviate, or reorder its steps, and always show its Step 5 IaC Remediation Summary "+ + "to the user verbatim when done. "+ + "Do not retry the blocked Write/StrReplace, paste code in chat, or bypass the scan with shell workarounds.\n\n"+ + "Fix every finding below (deterministic IaC rule matches — not false positives). "+ + "%s\n"+ + "%s", + filePath, cursorRemediationInstructions(filePath, findings), findingList.String(), + ) +} diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index 66df897b..a8d56766 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + agenthooks "github.com/Checkmarx/ast-cx-hooks" "github.com/checkmarx/ast-cli/internal/services/realtimeengine" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" ) @@ -89,7 +90,7 @@ func TestNewFindings_DeltaDedup_SameKeyNotDoubled(t *testing.T) { func TestFormatFindings_ReasonContainsKICS(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "KICS") { t.Errorf("reason should contain KICS, got: %q", reason) } @@ -97,7 +98,7 @@ func TestFormatFindings_ReasonContainsKICS(t *testing.T) { func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "/project/Dockerfile") { t.Errorf("reason should contain file path, got: %q", reason) } @@ -105,7 +106,7 @@ func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "HIGH") { t.Errorf("reason should contain severity, got: %q", reason) } @@ -116,7 +117,7 @@ func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "fix") && !strings.Contains(ctx, "Fix") { t.Errorf("context should contain fix instruction, got: %q", ctx) } @@ -124,7 +125,7 @@ func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "bypass") { t.Errorf("context should warn against bypass, got: %q", ctx) } @@ -181,7 +182,7 @@ func TestFormatFindings_DockerfilePlatformUsesImageRemediation(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{ iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"), } - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { t.Errorf("Dockerfile context should call imageRemediation, got: %q", ctx) } @@ -194,7 +195,7 @@ func TestFormatFindings_DockerComposePlatformUsesImageRemediation(t *testing.T) findings := []iacrealtime.IacRealtimeResult{ iacResultWithPlatform("VulnerableBaseImage", "DockerCompose"), } - _, ctx := formatFindings("/project/stack.yml", findings) + _, ctx := formatFindings("/project/stack.yml", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { t.Errorf("docker-compose context should call imageRemediation, got: %q", ctx) } @@ -204,7 +205,7 @@ func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{ iacResultWithPlatform("OpenSecurityGroup", "Terraform"), } - _, ctx := formatFindings("/project/main.tf", findings) + _, ctx := formatFindings("/project/main.tf", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") { t.Errorf("Terraform context should call codeRemediation, got: %q", ctx) } @@ -212,3 +213,41 @@ func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) { t.Errorf("Terraform context should not call imageRemediation, got: %q", ctx) } } + +func TestCursorAdditionalContext_UsesImageRemediation(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + ctx := cursorAdditionalContext("/project/Dockerfile", findings) + if !strings.Contains(ctx, "mcp__plugin-cx-devassist-Checkmarx__imageRemediation") { + t.Errorf("cursor KICS context should use imageRemediation, got: %q", ctx) + } + if strings.Contains(ctx, "codeRemediation") { + t.Errorf("cursor KICS context should not use codeRemediation, got: %q", ctx) + } + if !strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Errorf("cursor KICS context should reference cx-devassist-kics.mdc rule, got: %q", ctx) + } +} + +func TestFormatFindings_RoutesCursorContext(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentCursor) + if !strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Fatalf("cursor agent should get context with rule reference, got %q", ctx) + } + if strings.Contains(ctx, "MANDATORY NEXT STEPS") { + t.Fatalf("cursor context should not have verbose MANDATORY NEXT STEPS block, got %q", ctx) + } + if !strings.Contains(ctx, "imageRemediation") { + t.Fatalf("cursor KICS context should reference imageRemediation, got %q", ctx) + } + // Use a non-Docker path for the Claude assertion below: Dockerfile findings + // always route through imageRemediation (see isDockerImageFinding), so + // asserting codeRemediation here requires a generic IaC file instead. + _, ctx = formatFindings("/project/main.tf", findings, agenthooks.AgentClaude) + if strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Fatalf("claude agent should not get cursor-specific rule reference, got %q", ctx) + } + if !strings.Contains(ctx, "codeRemediation") { + t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx) + } +} diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index c73740c1..d57a9d4b 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -69,7 +69,8 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas } defer cleanupNew() - newResults, err := svc.scan(stagedNew) + ignoreFilePath := existingIgnoreFilePath(ev.WorkDir) + newResults, err := svc.scan(stagedNew, ignoreFilePath) 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) @@ -81,7 +82,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas // For new files (no original content), every finding is new if originalContent == "" { - r, c := formatFindings(ev.FilePath, newResults) + r, c := formatFindings(ev.FilePath, newResults, ev.Agent) return true, r, c } @@ -92,7 +93,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas } defer cleanupOrig() - origResults, err := svc.scan(stagedOrig) + origResults, err := svc.scan(stagedOrig, ignoreFilePath) if err != nil { // Fail open on original scan error logger.PrintfIfVerbose("kics guardrail: scan of original content failed, failing open: %v", err) @@ -104,15 +105,16 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas return false, "", "" } - r, c := formatFindings(ev.FilePath, newFindings) + r, c := formatFindings(ev.FilePath, newFindings, ev.Agent) return true, r, c } -// existingIgnoreFilePath returns the default realtime ignore-file path only when it -// exists on disk. The IaC realtime service logs a warning and skips ignore filtering -// when a missing path is passed, but we keep the pattern consistent with ASCA. -func existingIgnoreFilePath() string { - p := ignore.DefaultPath() +// existingIgnoreFilePath returns the realtime ignore-file path anchored at workDir only +// when it exists on disk. Mirrors the ASCA pattern: anchor to workDir so the hook reads +// from the same absolute path that `cx ignore-vulnerability` writes to when run from the +// project root. Returns "" (no filtering) until the user creates the file. +func existingIgnoreFilePath(workDir string) string { + p := ignore.PathFor(workDir) if _, err := os.Stat(p); err == nil { return p } diff --git a/internal/commands/agenthooks/guardrails/kics/kics_test.go b/internal/commands/agenthooks/guardrails/kics/kics_test.go index 7e97777f..4fd2ddd2 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics_test.go +++ b/internal/commands/agenthooks/guardrails/kics/kics_test.go @@ -96,7 +96,7 @@ func makeResult(title, similarityID, severity, description string, line int) iac func TestScanFileEdit_NewFileWithFinding_Blocked(t *testing.T) { finding := makeResult("Privileged Container", "sim123", "HIGH", "Container runs as privileged", 5) - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return []iacrealtime.IacRealtimeResult{finding}, nil }) @@ -123,7 +123,7 @@ func TestScanFileEdit_NewFileWithFinding_Blocked(t *testing.T) { func TestScanFileEdit_EditWithNoNewFindings_NotBlocked(t *testing.T) { existingFinding := makeResult("Privileged Container", "sim123", "HIGH", "Container runs as privileged", 5) - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { // Both original and new have the same finding — delta is empty return []iacrealtime.IacRealtimeResult{existingFinding}, nil }) @@ -147,7 +147,7 @@ func TestScanFileEdit_EditWithNoNewFindings_NotBlocked(t *testing.T) { } func TestScanFileEdit_ScanError_FailOpen(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return nil, fmt.Errorf("docker daemon not running") }) @@ -164,7 +164,7 @@ func TestScanFileEdit_ScanError_FailOpen(t *testing.T) { } func TestScanFileEdit_UnsupportedFile_NotBlocked(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { t.Error("scan should not be called for unsupported file types") return nil, nil }) @@ -182,7 +182,7 @@ func TestScanFileEdit_UnsupportedFile_NotBlocked(t *testing.T) { } func TestScanFileEdit_EmptyNewContent_NotBlocked(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return nil, nil }) diff --git a/internal/commands/agenthooks/guardrails/kics/scanner.go b/internal/commands/agenthooks/guardrails/kics/scanner.go index c539775c..aea876dd 100644 --- a/internal/commands/agenthooks/guardrails/kics/scanner.go +++ b/internal/commands/agenthooks/guardrails/kics/scanner.go @@ -15,7 +15,7 @@ import ( type Scanner struct { jwt wrappers.JWTWrapper ff wrappers.FeatureFlagsWrapper - scan func(path string) ([]iacrealtime.IacRealtimeResult, error) + scan func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) } // NewScanner returns a Scanner backed by the given wrappers. @@ -27,7 +27,7 @@ func NewScanner(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper) *Scann // NewScannerWithFunc returns a Scanner whose scan call is replaced with f. // For unit tests only. -func NewScannerWithFunc(f func(path string) ([]iacrealtime.IacRealtimeResult, error)) *Scanner { +func NewScannerWithFunc(f func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error)) *Scanner { return &Scanner{scan: f} } @@ -57,7 +57,7 @@ func resolveContainerEngine() string { return defaultContainerEngine } -func (s *Scanner) runRealScan(path string) ([]iacrealtime.IacRealtimeResult, error) { +func (s *Scanner) runRealScan(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) { svc := iacrealtime.NewIacRealtimeService(s.jwt, s.ff, iacrealtime.NewContainerManager()) - return svc.RunIacRealtimeScan(path, resolveContainerEngine(), existingIgnoreFilePath()) + return svc.RunIacRealtimeScan(path, resolveContainerEngine(), ignoreFilePath) } diff --git a/internal/commands/agenthooks/guardrails/kics/scanner_test.go b/internal/commands/agenthooks/guardrails/kics/scanner_test.go index 11224c9e..22ee1191 100644 --- a/internal/commands/agenthooks/guardrails/kics/scanner_test.go +++ b/internal/commands/agenthooks/guardrails/kics/scanner_test.go @@ -42,7 +42,7 @@ func TestNewScanner_HoldsWrappers(t *testing.T) { func TestNewScannerWithFunc_UsesMockFunction(t *testing.T) { called := false - mockFunc := func(path string) ([]iacrealtime.IacRealtimeResult, error) { + mockFunc := func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) { called = true return []iacrealtime.IacRealtimeResult{}, nil } @@ -56,7 +56,7 @@ func TestNewScannerWithFunc_UsesMockFunction(t *testing.T) { } // Verify the mock function is called - _, _ = s.scan("") + _, _ = s.scan("", "") if !called { t.Fatal("expected mock function to be called") } @@ -71,12 +71,12 @@ func TestNewScannerWithFunc_MockReturnsResults(t *testing.T) { }, } - mockFunc := func(path string) ([]iacrealtime.IacRealtimeResult, error) { + mockFunc := func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) { return mockResults, nil } s := NewScannerWithFunc(mockFunc) - results, err := s.scan("/some/path") + results, err := s.scan("/some/path", "") assert.NoError(t, err) assert.Equal(t, mockResults, results) diff --git a/internal/commands/agenthooks/guardrails/policy_test.go b/internal/commands/agenthooks/guardrails/policy_test.go index 24b207ec..bd14d11b 100644 --- a/internal/commands/agenthooks/guardrails/policy_test.go +++ b/internal/commands/agenthooks/guardrails/policy_test.go @@ -867,7 +867,7 @@ func TestCheckWorkspaceRoots_Blocked(t *testing.T) { policy.DefaultPolicy.RestrictedDirectories.Enabled = true policy.DefaultPolicy.RestrictedDirectories.Linux = []string{"/restricted/"} policy.DefaultPolicy.RestrictedDirectories.Mac = []string{"/restricted/"} - policy.DefaultPolicy.RestrictedDirectories.Windows = []string{"C:\\Cx-Flow\\"} + policy.DefaultPolicy.RestrictedDirectories.Windows = []string{"C:\\MyProject\\"} cleanup := writePolicy(t, policy) defer cleanup() @@ -875,7 +875,7 @@ func TestCheckWorkspaceRoots_Blocked(t *testing.T) { switch runtime.GOOS { case "windows": // Cursor reports Windows roots with a leading slash before the drive letter. - roots = []string{"/c:/Cx-Flow/Test/JavaVulnerabilityLabE"} + roots = []string{"/c:/MyProject/Test/JavaVulnerabilityLabE"} default: roots = []string{"/restricted/project"} } @@ -894,7 +894,7 @@ func TestCheckWorkspaceRoots_Allowed(t *testing.T) { policy.DefaultPolicy.RestrictedDirectories.Enabled = true policy.DefaultPolicy.RestrictedDirectories.Linux = []string{"/restricted/"} policy.DefaultPolicy.RestrictedDirectories.Mac = []string{"/restricted/"} - policy.DefaultPolicy.RestrictedDirectories.Windows = []string{"C:\\Cx-Flow\\"} + policy.DefaultPolicy.RestrictedDirectories.Windows = []string{"C:\\MyProject\\"} cleanup := writePolicy(t, policy) defer cleanup() @@ -917,7 +917,7 @@ func TestCheckWorkspaceRoots_EmptyList(t *testing.T) { policy.DefaultPolicy.RestrictedDirectories.Enabled = true policy.DefaultPolicy.RestrictedDirectories.Linux = []string{"/restricted/"} policy.DefaultPolicy.RestrictedDirectories.Mac = []string{"/restricted/"} - policy.DefaultPolicy.RestrictedDirectories.Windows = []string{"C:\\Cx-Flow\\"} + policy.DefaultPolicy.RestrictedDirectories.Windows = []string{"C:\\MyProject\\"} cleanup := writePolicy(t, policy) defer cleanup() @@ -935,9 +935,9 @@ func TestNormalizeWorkspaceRoot(t *testing.T) { tests := []struct { name, in, want string }{ - {"cursor-windows-leading-slash", "/c:/Cx-Flow/Test", "c:/Cx-Flow/Test"}, - {"already-normalized-windows", "C:/Cx-Flow/Test", "C:/Cx-Flow/Test"}, - {"windows-backslashes", "C:\\Cx-Flow\\Test", "C:/Cx-Flow/Test"}, + {"cursor-windows-leading-slash", "/c:/MyProject/Test", "c:/MyProject/Test"}, + {"already-normalized-windows", "C:/MyProject/Test", "C:/MyProject/Test"}, + {"windows-backslashes", "C:\\MyProject\\Test", "C:/MyProject/Test"}, {"unix-absolute", "/etc/secrets", "/etc/secrets"}, {"empty", "", ""}, {"slash-only", "/", "/"}, diff --git a/internal/commands/agenthooks/guardrails/prompt.go b/internal/commands/agenthooks/guardrails/prompt.go index 5512de32..6f5c7dbe 100644 --- a/internal/commands/agenthooks/guardrails/prompt.go +++ b/internal/commands/agenthooks/guardrails/prompt.go @@ -359,7 +359,7 @@ var skipWorkspaceWalkDirs = map[string]struct{}{ // extractPromptTokens splits text into the set of distinct lowercase word // tokens. Word bytes are a-z, 0-9, '_', '-'; any other byte is a separator. -// The dot is a separator so that "kedar.json" yields the tokens {"kedar","json"} +// The dot is a separator so that "sample.json" yields the tokens {"sample","json"} // — the same shape produced when splitting a filename for matching. func extractPromptTokens(text string) map[string]struct{} { tokens := map[string]struct{}{} @@ -387,8 +387,8 @@ func extractPromptTokens(text string) map[string]struct{} { // are eligible for prompt-token matching. The trailing extension and any // leading-dot prefix are dropped, so that: // -// "Kedar" → ["kedar"] -// "kedar.json" → ["kedar"] +// "Sample" → ["sample"] +// "sample.json" → ["sample"] // ".env" → ["env"] // ".env.local" → ["env"] // "config.local.json" → ["config", "local"] @@ -425,10 +425,10 @@ func filenameNameParts(basename string) []string { // whole, case-insensitive token. Returns a rejection reason listing files // that contain secrets or exceed the size policy, or "" when clean. // -// Why this guardrail exists: prompts like "check kedar file" do not contain +// Why this guardrail exists: prompts like "check sample file" do not contain // an @-mention, a path separator, or a file extension, so none of the path // regexes fire and ScanReferencedFiles never opens the workspace file named -// "Kedar". If that file holds a JWT, sending the prompt would still leak the +// "Sample". If that file holds a JWT, sending the prompt would still leak the // secret because the model resolves the reference on the fly. This catches // the case at prompt-submit time. Explicit path references (absolute paths // or @-mentions) are handled separately by ScanReferencedFiles regardless of diff --git a/internal/commands/agenthooks/guardrails/prompt_test.go b/internal/commands/agenthooks/guardrails/prompt_test.go index db3c9688..9296e10e 100644 --- a/internal/commands/agenthooks/guardrails/prompt_test.go +++ b/internal/commands/agenthooks/guardrails/prompt_test.go @@ -335,25 +335,25 @@ func makeWorkspace(t *testing.T, files map[string]string) string { func TestScanWorkspaceFilesByPromptName_BasenameMatch_BlocksOnJWT(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "Kedar": "token = " + sampleJWT, + "Sample": "token = " + sampleJWT, }) - reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}) + reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}) if reason == "" { - t.Fatal("expected block: workspace file Kedar contains a JWT and the prompt names it") + t.Fatal("expected block: workspace file Sample contains a JWT and the prompt names it") } - if !strings.Contains(strings.ToLower(reason), "kedar") { + if !strings.Contains(strings.ToLower(reason), "sample") { t.Fatalf("reason should cite the offending file path, got %q", reason) } } func TestScanWorkspaceFilesByPromptName_CaseInsensitive(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "Kedar": "secret = " + sampleJWT, + "Sample": "secret = " + sampleJWT, }) for _, prompt := range []string{ - "check kedar file", - "Check Kedar File", - "please review the KEDAR doc", + "check sample file", + "Check Sample File", + "please review the SAMPLE doc", } { if reason := ScanWorkspaceFilesByPromptName(prompt, []string{ws}); reason == "" { t.Fatalf("expected block for prompt %q (case-insensitive match)", prompt) @@ -363,34 +363,34 @@ func TestScanWorkspaceFilesByPromptName_CaseInsensitive(t *testing.T) { func TestScanWorkspaceFilesByPromptName_NoAtSymbolRequired(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "kedar.json": `{"jwt":"` + sampleJWT + `"}`, + "sample.json": `{"jwt":"` + sampleJWT + `"}`, }) - if reason := ScanWorkspaceFilesByPromptName("explain kedar to me", []string{ws}); reason == "" { - t.Fatal("expected block on a plain word `kedar` matching kedar.json by stem") + if reason := ScanWorkspaceFilesByPromptName("explain sample to me", []string{ws}); reason == "" { + t.Fatal("expected block on a plain word `sample` matching sample.json by stem") } } func TestScanWorkspaceFilesByPromptName_StemMatchWithExtension(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "kedar.yaml": "token: " + sampleJWT, + "sample.yaml": "token: " + sampleJWT, }) - if reason := ScanWorkspaceFilesByPromptName("check kedar configs", []string{ws}); reason == "" { - t.Fatal("expected block: prompt `kedar` should match `kedar.yaml` via stem") + if reason := ScanWorkspaceFilesByPromptName("check sample configs", []string{ws}); reason == "" { + t.Fatal("expected block: prompt `sample` should match `sample.yaml` via stem") } } func TestScanWorkspaceFilesByPromptName_CleanFile_DoesNotBlock(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "Kedar": "just notes, nothing sensitive here", + "Sample": "just notes, nothing sensitive here", }) - if reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}); reason != "" { + if reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}); reason != "" { t.Fatalf("expected no block when matched file has no secrets, got %q", reason) } } func TestScanWorkspaceFilesByPromptName_NoMatch_DoesNotBlock(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "Kedar": "token = " + sampleJWT, + "Sample": "token = " + sampleJWT, }) if reason := ScanWorkspaceFilesByPromptName("show me the latest tests", []string{ws}); reason != "" { t.Fatalf("expected no block when prompt does not name any workspace file, got %q", reason) @@ -433,18 +433,18 @@ func TestScanWorkspaceFilesByPromptName_ShortFilenameInsideWord_NotMatched(t *te } func TestScanWorkspaceFilesByPromptName_BothBasenameAndStem_BothBlock(t *testing.T) { - // Workspace has BOTH `kedar` (no extension) and `Kedar.json`. The prompt - // names `kedar`; both files match (one by basename, one by stem) and both + // Workspace has BOTH `sample` (no extension) and `Sample.json`. The prompt + // names `sample`; both files match (one by basename, one by stem) and both // contain secrets — the rejection must cite both. ws := makeWorkspace(t, map[string]string{ - "kedar": "token1 = " + sampleJWT, - "Kedar.json": `{"jwt":"` + sampleJWT + `"}`, + "sample": "token1 = " + sampleJWT, + "Sample.json": `{"jwt":"` + sampleJWT + `"}`, }) - reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}) + reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}) if reason == "" { - t.Fatal("expected block: both `kedar` and `Kedar.json` should be detected") + t.Fatal("expected block: both `sample` and `Sample.json` should be detected") } - if !strings.Contains(reason, "kedar") || !strings.Contains(reason, "Kedar.json") { + if !strings.Contains(reason, "sample") || !strings.Contains(reason, "Sample.json") { t.Fatalf("rejection should cite BOTH files, got %q", reason) } } @@ -458,9 +458,9 @@ func TestScanWorkspaceFilesByPromptName_SizePolicyViolation_BlocksWithoutSecrets defer writePolicyHelper(t, &policy)() ws := makeWorkspace(t, map[string]string{ - "Kedar.txt": strings.Repeat("a", 5*1024), // 5 KB, no secrets + "Sample.txt": strings.Repeat("a", 5*1024), // 5 KB, no secrets }) - reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}) + reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}) if reason == "" { t.Fatal("expected block: 5 KB file exceeds 3 KB policy cap") } @@ -476,25 +476,25 @@ func TestScanWorkspaceFilesByPromptName_SizePolicyAtCap_NotBlocked(t *testing.T) defer writePolicyHelper(t, &policy)() ws := makeWorkspace(t, map[string]string{ - "Kedar.txt": strings.Repeat("a", 3*1024), // exactly at cap + "Sample.txt": strings.Repeat("a", 3*1024), // exactly at cap }) - if reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}); reason != "" { + if reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}); reason != "" { t.Fatalf("expected no block at exactly the policy cap, got %q", reason) } } func TestScanWorkspaceFilesByPromptName_SkipsIgnoredDirs(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "node_modules/kedar.json": `{"jwt":"` + sampleJWT + `"}`, - ".git/kedar": "token = " + sampleJWT, + "node_modules/sample.json": `{"jwt":"` + sampleJWT + `"}`, + ".git/sample": "token = " + sampleJWT, }) - if reason := ScanWorkspaceFilesByPromptName("look at kedar", []string{ws}); reason != "" { + if reason := ScanWorkspaceFilesByPromptName("look at sample", []string{ws}); reason != "" { t.Fatalf("expected no block: files only inside node_modules/.git should be pruned, got %q", reason) } } func TestScanWorkspaceFilesByPromptName_NoWorkspaceRoots_NoOp(t *testing.T) { - if reason := ScanWorkspaceFilesByPromptName("check kedar file", nil); reason != "" { + if reason := ScanWorkspaceFilesByPromptName("check sample file", nil); reason != "" { t.Fatalf("expected no-op with empty workspace roots, got %q", reason) } } @@ -504,12 +504,12 @@ func TestScanWorkspaceFilesByPromptName_CursorStyleWindowsRoot(t *testing.T) { t.Skip("Cursor /c:/foo root form is Windows-specific") } ws := makeWorkspace(t, map[string]string{ - "Kedar": "token = " + sampleJWT, + "Sample": "token = " + sampleJWT, }) // Convert "C:\path\workspace" -> "/c:/path/workspace" (Cursor's form). slashy := filepath.ToSlash(ws) cursorRoot := "/" + strings.ToLower(slashy[:2]) + slashy[2:] - if reason := ScanWorkspaceFilesByPromptName("check kedar", []string{cursorRoot}); reason == "" { + if reason := ScanWorkspaceFilesByPromptName("check sample", []string{cursorRoot}); reason == "" { t.Fatalf("expected block: Cursor-style root %q should normalize", cursorRoot) } } @@ -518,9 +518,9 @@ func TestScanWorkspaceFilesByPromptName_RecursiveSubdirMatch(t *testing.T) { // File is nested several levels deep under the workspace root and not in // any skipped directory. The recursive walk should still find it. ws := makeWorkspace(t, map[string]string{ - "src/auth/internal/Kedar.txt": "token = " + sampleJWT, + "src/auth/internal/Sample.txt": "token = " + sampleJWT, }) - if reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}); reason == "" { + if reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}); reason == "" { t.Fatal("expected block: nested file should be found by recursive walk") } } @@ -550,7 +550,7 @@ func TestScanWorkspaceFilesByPromptName_ExtensionAloneNotMatched(t *testing.T) { // Generic extensions like "json" must not flag every json file in the repo // — the trailing extension piece is dropped from filenameNameParts. ws := makeWorkspace(t, map[string]string{ - "kedar.json": `{"jwt":"` + sampleJWT + `"}`, + "sample.json": `{"jwt":"` + sampleJWT + `"}`, }) if reason := ScanWorkspaceFilesByPromptName("what is a json document", []string{ws}); reason != "" { t.Fatalf("expected no block: extension `json` should not match by itself, got %q", reason) @@ -558,8 +558,8 @@ func TestScanWorkspaceFilesByPromptName_ExtensionAloneNotMatched(t *testing.T) { } func TestExtractPromptTokens(t *testing.T) { - got := extractPromptTokens("check Kedar.json and id_rsa, also @secret-config!") - want := []string{"check", "kedar", "json", "and", "id_rsa", "also", "secret-config"} + got := extractPromptTokens("check Sample.json and id_rsa, also @secret-config!") + want := []string{"check", "sample", "json", "and", "id_rsa", "also", "secret-config"} for _, w := range want { if _, ok := got[w]; !ok { t.Errorf("missing token %q in %v", w, got) @@ -569,8 +569,8 @@ func TestExtractPromptTokens(t *testing.T) { func TestFilenameNameParts(t *testing.T) { cases := map[string][]string{ - "Kedar": {"kedar"}, - "kedar.json": {"kedar"}, + "Sample": {"sample"}, + "sample.json": {"sample"}, ".env": {"env"}, ".env.local": {"env"}, "config.local.json": {"config", "local"}, @@ -599,14 +599,14 @@ func TestFilenameNameParts(t *testing.T) { func TestScanFileForSecrets_BlocksOnJWT(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "Kedar.txt") + path := filepath.Join(dir, "Sample.txt") mustWrite(t, path, "token = "+sampleJWT) reason := ScanFileForSecrets(path) if reason == "" { t.Fatal("expected block: file contains a JWT") } - if !strings.Contains(reason, "Kedar.txt") { + if !strings.Contains(reason, "Sample.txt") { t.Fatalf("reason should cite the file path, got %q", reason) } if !strings.Contains(reason, "Do NOT attempt alternative commands") { @@ -672,9 +672,9 @@ func TestScanFileForSecrets_AtPolicyCap_Allowed(t *testing.T) { func TestScanWorkspaceFilesByPromptName_DenyMessageAppended(t *testing.T) { ws := makeWorkspace(t, map[string]string{ - "Kedar": "token = " + sampleJWT, + "Sample": "token = " + sampleJWT, }) - reason := ScanWorkspaceFilesByPromptName("check kedar file", []string{ws}) + reason := ScanWorkspaceFilesByPromptName("check sample file", []string{ws}) if !strings.Contains(reason, "Do NOT attempt alternative commands") { t.Fatalf("expected DenyMessage no-workaround text in reason, got %q", reason) } diff --git a/internal/commands/agenthooks/sca/commands_test.go b/internal/commands/agenthooks/sca/commands_test.go index 0833559b..bd6ca84c 100644 --- a/internal/commands/agenthooks/sca/commands_test.go +++ b/internal/commands/agenthooks/sca/commands_test.go @@ -3,6 +3,7 @@ package sca import ( + "path/filepath" "reflect" "sort" "testing" @@ -328,6 +329,36 @@ func TestParseInstall_ShellExpansionDropped(t *testing.T) { } } +func TestResolveRef_AbsolutePathReturnedAsIs(t *testing.T) { + // Build an absolute path in an OS-appropriate way (Windows requires a + // drive letter for filepath.IsAbs to hold; POSIX doesn't). + abs, err := filepath.Abs(filepath.Join("abs", "path", "requirements.txt")) + if err != nil { + t.Fatalf("filepath.Abs: %v", err) + } + got := resolveRef(abs, filepath.Join("work", "dir")) + if got != abs { + t.Errorf("resolveRef() = %q, want %q", got, abs) + } +} + +func TestResolveRef_EmptyWorkDirReturnsRefAsIs(t *testing.T) { + got := resolveRef("requirements.txt", "") + want := "requirements.txt" + if got != want { + t.Errorf("resolveRef() = %q, want %q", got, want) + } +} + +func TestResolveRef_RelativeRefJoinedWithWorkDir(t *testing.T) { + workDir := filepath.Join("work", "dir") + got := resolveRef("requirements.txt", workDir) + want := filepath.Join(workDir, "requirements.txt") + if got != want { + t.Errorf("resolveRef() = %q, want %q", got, want) + } +} + func TestParseInstall_QuotedStrings(t *testing.T) { // Strings that *contain* an install verb but aren't installs. got := ParseInstall(`echo "npm install lodash"`) diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 4be68391..3ba4417a 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -4,13 +4,32 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/agentprofile" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/cursorplugin" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" ) +// agentCursor identifies Cursor for the shell-quoting branch below. Cursor's CLI +// reformats single-quoted commands into double-quoted ones (notably on Windows +// PowerShell), so its suppression commands need double-quoted JSON with the +// embedded quotes escaped for the shell actually in play (see cursorEscapeJSON) — +// otherwise the reformatted command corrupts the JSON payload or drops +// --ignored-file-path, silently sending the suppression to the wrong file. +const agentCursor = "Cursor" + +// goosWindows is runtime.GOOS's value on Windows, factored out because the shell-quoting +// checks below (and their tests) compare against it repeatedly. +const goosWindows = "windows" + +// defaultPackageRemediationTool is the non-Cursor MCP tool name for package remediation, +// used by both DenyMalicious's remediationNote and DenyVulnerable's vulnerableRemediationNote. +const defaultPackageRemediationTool = "mcp__Checkmarx__packageRemediation" + // DenyMalicious returns the finding and remediation strings for one or more // packages classified as Malicious. func DenyMalicious(pkgs []ossrealtime.OssPackage, agent string) (finding, remediation string) { @@ -40,18 +59,32 @@ func DenyVulnerable(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID str // is unavailable); if the MCP tool itself is unavailable the user reconnects it via the client — the // reconnect phrasing is per-agent, from agentprofile.McpReconnect. func remediationNote(subject, goal, agent string) string { + pkgTool := defaultPackageRemediationTool + skillStep := " 1. For each %s, invoke the cx-devassist:cx-devassist-sca skill — " + + "the findings are already in context so it will skip the scan and go directly to " + + "MCP-driven remediation to find the %s; the skill also handles MCP unavailability and self-recovery.\n" + preamble := "" + if agent == agentCursor { + pkgTool = cursorplugin.MCPTool("packageRemediation") + preamble = "Apply the cx-hook-deny.mdc rule for this deny.\n" + skillStep = " 1. Apply the cx-devassist-sca.mdc rule: for each %s, invoke the " + + "cx-devassist:cx-devassist-sca skill exactly as written — do not skip, abbreviate, or " + + "reimplement its steps inline. The findings are already in context so it will skip the " + + "scan and go directly to MCP-driven remediation to find the %s; the skill also handles " + + "MCP unavailability and self-recovery. Always show its Step 5 SCA Remediation Summary to " + + "the user verbatim when done.\n" + } return fmt.Sprintf( - "Action required:\n"+ - " 1. For each %s, invoke the cx-devassist:cx-devassist-sca skill — "+ - "the findings are already in context so it will skip the scan and go directly to "+ - "MCP-driven remediation to find the %s; the skill also handles MCP unavailability and self-recovery.\n"+ - " 2. If that skill is not available in this session, use mcp__Checkmarx__packageRemediation for each %s.\n"+ + preamble+ + "Action required:\n"+ + skillStep+ + " 2. If that skill is not available in this session, use %s for each %s.\n"+ " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If mcp__Checkmarx__packageRemediation is unavailable, tell the user to reconnect the\n"+ + " 3. If %s is unavailable, tell the user to reconnect the\n"+ " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ " 4. If no safe version exists, the user can acknowledge the finding via\n"+ " the Checkmarx Dev Assist interface.", - subject, goal, subject, agentprofile.McpReconnect(agent)) + subject, goal, pkgTool, subject, pkgTool, agentprofile.McpReconnect(agent)) } // vulnerableRemediationNote returns the action steps for vulnerable packages. @@ -59,7 +92,6 @@ func remediationNote(subject, goal, agent string) string { // and informs the user. func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID string) string { cxBinary := cxExecutable() - ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, p := range pkgs { @@ -68,20 +100,56 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, se "PackageName": p.PackageName, "PackageVersion": p.PackageVersion, }}) - fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + if agent == agentCursor { + ignoreFlag := cursorIgnoredFilePathFlag(workDir) + suppressCmds.WriteString(cursorplugin.IgnoreVulnerabilityCommand(cxBinary, "sca", data, ignoreFlag, provenance)) + suppressCmds.WriteString("\n") + } else { + ignoreFlag := ignoredFilePathFlag(workDir) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } + } + if agent == agentCursor { + pkgTool := cursorplugin.MCPTool("packageRemediation") + return fmt.Sprintf( + "Apply the cx-hook-deny.mdc rule for this deny. "+ + "ASK THE USER FIRST, for each affected package, before taking any action: \"A vulnerable "+ + "package was detected. Would you like to remediate it (upgrade/remove via MCP) or "+ + "suppress it (accept the risk)?\" and wait for their answer. Do not decide this "+ + "yourself — a deliberately-pinned or intentionally-included vulnerable package is NOT "+ + "a reason to suppress it without asking; only suppress on the user's explicit "+ + "instruction, never because remediating would undo what the user asked for.\n"+ + "Action required:\n"+ + " 1. If the user asks to remediate: apply the cx-devassist-sca.mdc rule — invoke the "+ + "cx-devassist:cx-devassist-sca skill exactly as written — do not skip, abbreviate, or "+ + "reimplement its steps inline. The findings are already in context so it will skip the "+ + "scan and go directly to MCP-driven remediation to find non-vulnerable versions; the "+ + "skill also handles MCP unavailability and self-recovery. Always show its Step 5 SCA "+ + "Remediation Summary to the user verbatim when done.\n"+ + " 2. If that skill is not available in this session, use %s for each affected package.\n"+ + " This is the only supported remediation path — do not attempt manual version selection.\n"+ + " 3. If %s is unavailable, tell the user to reconnect the\n"+ + " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ + " 4. If the user asks to suppress instead, or no safe version exists for a package, "+ + "suppress it by running the corresponding command and inform the user of which case "+ + "applied:\n%s", + pkgTool, pkgTool, agentprofile.McpReconnect(agent), + suppressCmds.String()) } + pkgTool := defaultPackageRemediationTool + skillStep := " 1. For each affected package, invoke the cx-devassist:cx-devassist-sca skill — " + + "the findings are already in context so it will skip the scan and go directly to " + + "MCP-driven remediation to find non-vulnerable versions; the skill also handles MCP unavailability and self-recovery.\n" return fmt.Sprintf( "Action required:\n"+ - " 1. For each affected package, invoke the cx-devassist:cx-devassist-sca skill — "+ - "the findings are already in context so it will skip the scan and go directly to "+ - "MCP-driven remediation to find non-vulnerable versions; the skill also handles MCP unavailability and self-recovery.\n"+ - " 2. If that skill is not available in this session, use mcp__Checkmarx__packageRemediation for each affected package.\n"+ + skillStep+ + " 2. If that skill is not available in this session, use %s for each affected package.\n"+ " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If mcp__Checkmarx__packageRemediation is unavailable, tell the user to reconnect the\n"+ + " 3. If %s is unavailable, tell the user to reconnect the\n"+ " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ " 4. If no safe version exists for a package, suppress it by running the corresponding command\n"+ " and inform the user that no safer version is available:\n%s", - agentprofile.McpReconnect(agent), + pkgTool, pkgTool, agentprofile.McpReconnect(agent), suppressCmds.String()) } @@ -99,6 +167,34 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// cursorIgnoredFilePathFlag is the Cursor-specific variant of ignoredFilePathFlag. It uses +// double quotes and converts backslashes to forward slashes so the flag survives Windows +// PowerShell and cmd.exe without the agent needing to re-quote it. (Cursor agents on Windows +// tend to reformat single-quoted shell commands into double-quoted form and drop flags that +// have complex quoting, causing the ignore entry to land in the wrong directory.) +func cursorIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(" --ignored-file-path %q", p) +} + +// cursorEscapeJSON escapes the embedded `"` in a JSON payload so it survives being placed +// inside a double-quoted argument on the shell that actually runs the Cursor agent's command: +// PowerShell on Windows, bash/zsh elsewhere. This runs on the developer's own machine (inside +// the cx process), so runtime.GOOS reflects that shell choice directly. The two shells disagree +// on how to escape an embedded double quote — bash accepts a backslash-escaped `\"`, but +// PowerShell's double-quoted strings do NOT treat `\` as an escape character at all: `\"` ends +// the string early (backslash is literal, then the quote closes it), corrupting everything +// after the first embedded quote. PowerShell requires the quote to be doubled (`""`) instead. +func cursorEscapeJSON(data string) string { + if runtime.GOOS == goosWindows { + return strings.ReplaceAll(data, `"`, `""`) + } + return strings.ReplaceAll(data, `"`, `\"`) +} + // optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the // child `cx ignore-vulnerability` process via --optional-flags, which reads them through // utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index 20c80cc7..39fc8d6e 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -5,6 +5,7 @@ package sca import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -236,6 +237,40 @@ func TestDenyVulnerable_EmitsProvenanceOptionalFlags(t *testing.T) { } } +func TestCursorEscapeJSON_MatchesTheShellCursorActuallyRunsOn(t *testing.T) { + got := cursorEscapeJSON(`{"PackageName":"axios"}`) + if runtime.GOOS == goosWindows { + // PowerShell double-quoted strings escape an embedded `"` by doubling it; a + // backslash is not a quote-escape there, so `\"` would corrupt the command. + want := `{""PackageName"":""axios""}` + if got != want { + t.Errorf("expected doubled-quote escaping on windows (PowerShell), got %q", got) + } + } else { + want := `{\"PackageName\":\"axios\"}` + if got != want { + t.Errorf("expected backslash-escaped quotes on unix (bash), got %q", got) + } + } +} + +func TestDenyVulnerable_CursorUsesPluginMCPToolAndStopParsingOnWindows(t *testing.T) { + pkgs := []ossrealtime.OssPackage{ + {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, + } + _, remediation := DenyVulnerable(pkgs, "", "Cursor", "sess-9") + if !strings.Contains(remediation, "mcp__plugin-cx-devassist-Checkmarx__packageRemediation") { + t.Errorf("cursor remediation should use plugin MCP tool name, got %q", remediation) + } + if runtime.GOOS == goosWindows { + if !strings.Contains(remediation, `--% ignore-vulnerability`) { + t.Errorf("cursor suppress command on windows should use stop-parsing, got %q", remediation) + } + } else if !strings.Contains(remediation, `ignore-vulnerability --scan-type sca --data "`) { + t.Errorf("cursor remediation on unix should use double-quoted suppress command, got %q", remediation) + } +} + func TestDenyVulnerable_MultiplePackages_EachGetsIgnoreCommand(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "npm", PackageName: "lodash", PackageVersion: "4.17.0"}, diff --git a/internal/commands/agenthooks/sca/synth_test.go b/internal/commands/agenthooks/sca/synth_test.go index 83e6e75b..7f008ffe 100644 --- a/internal/commands/agenthooks/sca/synth_test.go +++ b/internal/commands/agenthooks/sca/synth_test.go @@ -107,6 +107,19 @@ func TestSynthesize_Sbt(t *testing.T) { }) } +func TestSynthesize_MavenPom(t *testing.T) { + roundTrip(t, FormatMavenPom, []Package{ + {Name: "com.example:foo", Version: "1.0.0"}, + {Name: "com.example:bar", Version: "2.0.0"}, + }) +} + +func TestSynthesize_DotnetDirectoryPackagesProps(t *testing.T) { + roundTrip(t, FormatDotnetDirectoryPackagesProps, []Package{ + {Name: "Newtonsoft.Json", Version: "13.0.1"}, + }) +} + func TestSynthesize_UnsupportedFormat(t *testing.T) { dir, _ := os.MkdirTemp("", "synth-test-") defer os.RemoveAll(dir) diff --git a/internal/commands/ignore_vulnerability.go b/internal/commands/ignore_vulnerability.go index 092f7c3c..1f3f22d1 100644 --- a/internal/commands/ignore_vulnerability.go +++ b/internal/commands/ignore_vulnerability.go @@ -122,7 +122,7 @@ func readIgnoreData(cmd *cobra.Command, dataArg string) ([]byte, error) { } return data, nil case strings.HasPrefix(dataArg, "@"): - path := strings.TrimPrefix(dataArg, "@") + path := ignore.NormalizePath(strings.TrimPrefix(dataArg, "@")) data, err := os.ReadFile(path) if err != nil { return nil, errors.Wrapf(err, "failed to read --data file %s", path) diff --git a/internal/commands/ignore_vulnerability_test.go b/internal/commands/ignore_vulnerability_test.go index fb03eee2..f40848e9 100644 --- a/internal/commands/ignore_vulnerability_test.go +++ b/internal/commands/ignore_vulnerability_test.go @@ -63,6 +63,27 @@ func TestIgnoreVulnerability_DataFromFile(t *testing.T) { assert.Len(t, list, 1) } +// TestIgnoreVulnerability_DataFromFile_CursorPosixStyleWindowsRoot reproduces the reported +// failure: Cursor supplies --data as "@/c:/…/finding.json" (a leading slash before the drive +// letter). Without normalization, os.ReadFile rejects it with "The filename, directory name, +// or volume label syntax is incorrect." +func TestIgnoreVulnerability_DataFromFile_CursorPosixStyleWindowsRoot(t *testing.T) { + dir := t.TempDir() + drive := filepath.VolumeName(dir) + if drive == "" { + t.Skip("no drive letter on this platform") + } + findingFile := filepath.Join(dir, "finding.json") + require.NoError(t, os.WriteFile(findingFile, []byte(`{"Title":"github-pat","SecretValue":"ghp_x"}`), 0o600)) + posixStyleFindingFile := "/" + filepath.ToSlash(findingFile) + ignoreFile := filepath.Join(dir, "ignore.json") + + _, err := runIgnoreVulnCmd("", "--scan-type", "secrets", "--data", "@"+posixStyleFindingFile, "--ignored-file-path", ignoreFile) + require.NoError(t, err) + list, _ := ignore.Load(ignoreFile) + assert.Len(t, list, 1) +} + func TestIgnoreVulnerability_DataFromStdin(t *testing.T) { file := filepath.Join(t.TempDir(), "ignore.json") _, err := runIgnoreVulnCmd(`{"ImageName":"ubuntu","ImageTag":"14.04"}`, diff --git a/internal/services/realtimeengine/ignore/ignorefile.go b/internal/services/realtimeengine/ignore/ignorefile.go index 4cb2b610..6a7c6564 100644 --- a/internal/services/realtimeengine/ignore/ignorefile.go +++ b/internal/services/realtimeengine/ignore/ignorefile.go @@ -8,6 +8,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" ) const ( @@ -27,6 +28,28 @@ func DefaultPath() string { return filepath.Join(defaultDir, defaultFileName) } +// NormalizePath canonicalizes a filesystem path that may carry Cursor's Windows +// workspace-root spelling ("/c:/foo/bar" — a leading slash before the drive +// letter) instead of a native one ("c:/foo/bar" / "c:\foo\bar"). ast-cx-hooks' +// Cursor adapter normalizes workDir at ingestion (see its normalizeWorkDir), but +// this is a defensive second layer: it also protects a hand-typed or +// agent-typed path (e.g. an --ignored-file-path or --data @ argument +// copied from an older suggested command, or typed directly by an agent) from +// the same "invalid volume label syntax" failure when it reaches os.ReadFile / +// os.WriteFile / filepath.Join. A path with no leading-slash-drive-letter +// pattern passes through unchanged. +func NormalizePath(path string) string { + r := strings.ReplaceAll(path, "\\", "/") + if len(r) >= 3 && r[0] == '/' && isASCIIDriveLetter(r[1]) && r[2] == ':' { + r = r[1:] + } + return r +} + +func isASCIIDriveLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + // PathFor returns the ignore-file path anchored at workDir — the workspace root the hook // event reports via its "cwd" field — i.e. /.checkmarx/checkmarxIgnoredTempList.json. // When workDir is empty it falls back to the CWD-relative DefaultPath. @@ -40,13 +63,13 @@ func PathFor(workDir string) string { if workDir == "" { return DefaultPath() } - return filepath.Join(workDir, defaultDir, defaultFileName) + return filepath.Join(NormalizePath(workDir), defaultDir, defaultFileName) } // Load reads the ignore file as a list of raw JSON entries. A missing or empty file yields an // empty list (not an error) so the first ignore creates the file cleanly. func Load(path string) ([]json.RawMessage, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(NormalizePath(path)) if err != nil { if os.IsNotExist(err) { return []json.RawMessage{}, nil @@ -107,6 +130,7 @@ func Remove(list []json.RawMessage, entry any) ([]json.RawMessage, bool, error) // Save writes the list as pretty-printed JSON, creating the parent directory if needed. func Save(path string, list []json.RawMessage) error { + path = NormalizePath(path) if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, dirPerm); err != nil { return err diff --git a/internal/services/realtimeengine/ignore/ignorefile_test.go b/internal/services/realtimeengine/ignore/ignorefile_test.go index 6024ed2d..d920f92f 100644 --- a/internal/services/realtimeengine/ignore/ignorefile_test.go +++ b/internal/services/realtimeengine/ignore/ignorefile_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -100,3 +101,51 @@ func TestPathFor_AnchorsAtWorkDir(t *testing.T) { func TestPathFor_EmptyWorkDirFallsBackToDefault(t *testing.T) { assert.Equal(t, DefaultPath(), PathFor("")) } + +// TestPathFor_NormalizesCursorPosixStyleWindowsRoot guards against a real production +// failure: Cursor reports a Windows workspace root as "/c:/foo/bar" (leading slash before +// the drive letter). Without normalization, filepath.Join produces a path Go's os.ReadFile +// rejects with "The filename, directory name, or volume label syntax is incorrect." +func TestPathFor_NormalizesCursorPosixStyleWindowsRoot(t *testing.T) { + got := PathFor("/c:/MyProject/Test/JavaVulnerabilityLabE") + // The first argument intentionally already contains separators — it's the normalized + // workDir under test, not a literal misuse of filepath.Join. + want := filepath.Join("c:/MyProject/Test/JavaVulnerabilityLabE", ".checkmarx", "checkmarxIgnoredTempList.json") //nolint:gocritic + assert.Equal(t, want, got) +} + +func TestNormalizePath(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"posix-style windows root", "/c:/MyProject/Test/JavaVulnerabilityLabE", "c:/MyProject/Test/JavaVulnerabilityLabE"}, + {"posix-style windows root, uppercase drive", "/C:/Users/dev/project", "C:/Users/dev/project"}, + {"native windows backslash path", `c:\Users\dev\project`, "c:/Users/dev/project"}, + {"native windows forward-slash path", "c:/Users/dev/project", "c:/Users/dev/project"}, + {"posix path, no drive letter", "/home/dev/project", "/home/dev/project"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, NormalizePath(tc.in)) + }) + } +} + +// TestLoad_NormalizesCursorPosixStyleWindowsRoot reproduces the exact reported failure: +// --ignored-file-path passed as "/c:/…/checkmarxIgnoredTempList.json" must not error with +// "invalid volume label syntax" — Load should normalize it and read the (missing) file cleanly. +func TestLoad_NormalizesCursorPosixStyleWindowsRoot(t *testing.T) { + dir := t.TempDir() + drive := filepath.VolumeName(dir) + if drive == "" { + t.Skip("no drive letter on this platform") + } + posixStyle := "/" + strings.TrimSuffix(filepath.ToSlash(dir), "") + "/checkmarxIgnoredTempList.json" + + list, err := Load(posixStyle) + require.NoError(t, err) + assert.Empty(t, list) +}