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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli
go 1.26.6

require (
github.com/Checkmarx/ast-cx-hooks v1.0.9
github.com/Checkmarx/ast-cx-hooks v1.0.10
github.com/Checkmarx/containers-resolver v1.0.37
github.com/Checkmarx/containers-types v1.0.9
github.com/Checkmarx/gen-ai-prompts v0.0.0-20240807143411-708ceec12b63
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Checkmarx/ast-cx-hooks v1.0.9 h1:NZ8Ekjxbe/0tj48Lv1rKVzwo2iGMXG06vD6HK0eZgio=
github.com/Checkmarx/ast-cx-hooks v1.0.9/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY=
github.com/Checkmarx/ast-cx-hooks v1.0.10 h1:tY4XwacIOnMelOYZFsN7Mpv2qcfQWzjcCLSoD8Bpd7k=
github.com/Checkmarx/ast-cx-hooks v1.0.10/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.37 h1:VkLuw8QXt04VvdWzjfoTHAqC7imizUp3fvLaTRRmHYs=
Expand Down
32 changes: 24 additions & 8 deletions internal/commands/agenthooks/cx/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/asca"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/kics"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/secrets"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/sessiontally"
"github.com/checkmarx/ast-cli/internal/wrappers"
Expand All @@ -19,9 +20,10 @@ import (
// canonical per-engine labels for the session summary (normalizes the legacy SCA/Oss split used by
// the per-event remediation telemetry; ASCA already uses "Asca").
const (
engineAsca = "Asca"
engineSca = "Sca"
engineKics = "Kics"
engineAsca = "Asca"
engineSca = "Sca"
engineKics = "Kics"
engineSecrets = "Secrets"
)

// scaScanner is the package-level SCA scanner used by the guardrail handlers.
Expand All @@ -33,6 +35,10 @@ var scaScanner *sca.Scanner
// It is set by RegisterGuardrails and cleared by RegisterPassThrough.
var kicsScanner *kics.Scanner

// secretsScanner is the package-level secret-detection scanner used by the file-edit guardrail.
// It is set by RegisterGuardrails and cleared by RegisterPassThrough.
var secretsScanner *secrets.Scanner

var telemetryWrapper wrappers.TelemetryWrapper

func cxWhenAgentIdle(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict {
Expand Down Expand Up @@ -81,10 +87,11 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict {
// 1. File EDITS (Claude / Windsurf / Droid / Gemini) — ev.Changes is populated.
// Enforce blast_radius_limit, files_limits.max_total_file_size_kb, the ASCA
// guardrail (AI-introduced code vulnerabilities), the KICS guardrail
// (IaC security vulnerabilities), and the SCA guardrail
// (malicious / vulnerable manifest additions) before any bytes are written
// to disk. MultiEdit and multi-file edits are handled uniformly by iterating
// ev.Changes.
// (IaC security vulnerabilities), the SCA guardrail
// (malicious / vulnerable manifest additions), and the secret-detection
// guardrail (hardcoded credentials introduced by the edit) before any bytes
// are written to disk. MultiEdit and multi-file edits are handled uniformly
// by iterating ev.Changes.
//
// 2. Cursor file READS (beforeReadFile) — ev.Changes is empty and ev.FilePath
// points to a file the agent is about to ingest into the LLM context.
Expand Down Expand Up @@ -135,6 +142,13 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict {
}
}
}
if secretsScanner != nil {
if blocked, reason, context, severity := secrets.ScanFileEdit(&ev, secretsScanner, telemetryWrapper, agent); blocked {
sessiontally.Add(ev.SessionID, engineSecrets, 1, 1)
logRemediationTelemetry(agent, "Secrets", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
}
}
// A note on an ALLOW, deliberately: blocking every IaC edit because a
// container engine is down would be worse than an unscanned edit.
if kicsNote != "" {
Expand Down Expand Up @@ -223,10 +237,11 @@ func promptWorkspaceRoots(raw any) []string {
}

// RegisterGuardrails wires the four guardrail handlers and instantiates the
// SCA and KICS scanners used by the Bash and FileEdit handlers.
// SCA, KICS, and secret-detection scanners used by the Bash and FileEdit handlers.
func RegisterGuardrails(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper, rt wrappers.RealtimeScannerWrapper, tel wrappers.TelemetryWrapper) {
scaScanner = sca.NewScanner(jwt, ff, rt)
kicsScanner = kics.NewScanner(jwt, ff)
secretsScanner = secrets.NewScanner(jwt, ff)
telemetryWrapper = tel
agenthooks.WhenAgentIdle(cxWhenAgentIdle)
agenthooks.BeforeToolCall(cxBeforeToolCall)
Expand All @@ -239,6 +254,7 @@ func RegisterGuardrails(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper
func RegisterPassThrough() {
scaScanner = nil
kicsScanner = nil
secretsScanner = nil
agenthooks.WhenAgentIdle(func(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() })
agenthooks.BeforeToolCall(func(_ agenthooks.ToolCallEvent) agenthooks.ToolVerdict { return agenthooks.Allow() })
agenthooks.BeforeFileEdit(func(_ agenthooks.FileEditEvent) agenthooks.FileEditVerdict { return agenthooks.AcceptEdit() })
Expand Down
75 changes: 68 additions & 7 deletions internal/commands/agenthooks/cx/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@

"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/kics"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/secrets"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/secretsrealtime"
"github.com/checkmarx/ast-cli/internal/wrappers"

"github.com/Checkmarx/ast-cx-hooks/cursor"
Expand Down Expand Up @@ -53,16 +55,18 @@

func resetHookGlobals(t *testing.T) {
t.Helper()
prevSCA, prevKICS, prevTel := scaScanner, kicsScanner, telemetryWrapper
prevSCA, prevKICS, prevSecrets, prevTel := scaScanner, kicsScanner, secretsScanner, telemetryWrapper
t.Cleanup(func() {
scaScanner = prevSCA
kicsScanner = prevKICS
secretsScanner = prevSecrets
telemetryWrapper = prevTel
guardrails.ResetBlastRadiusCount()
guardrails.ResetTotalFileSizeCount()
})
scaScanner = nil
kicsScanner = nil
secretsScanner = nil
telemetryWrapper = nil
guardrails.ResetBlastRadiusCount()
guardrails.ResetTotalFileSizeCount()
Expand Down Expand Up @@ -415,6 +419,51 @@
}
}

func TestCxBeforeFileEdit_SecretsFinding_RejectsWithContext(t *testing.T) {
resetHookGlobals(t)
const secret = "ghp_hook_test_secret_value"
tel := &recordingTelemetry{}
telemetryWrapper = tel
secretsScanner = secrets.NewScannerWithFunc(func(_, content, _ string) ([]secretsrealtime.SecretsRealtimeResult, error) {
if strings.Contains(content, secret) {
return []secretsrealtime.SecretsRealtimeResult{{
Title: "github-pat",
Description: "GitHub personal access token",
SecretValue: secret,
Severity: "Critical",
Locations: []realtimeengine.Location{{Line: 1}},
}}, nil
}
return nil, nil
})

v := cxBeforeFileEdit(agenthooks.FileEditEvent{
Agent: agenthooks.AgentGemini,
SessionID: "sec-sess",
FilePath: "config.env",
WorkDir: t.TempDir(),
Changes: []agenthooks.FileDiff{{Before: "", After: "TOKEN=" + secret}},
})
if v.Permit {
t.Fatal("secret finding should RejectEdit")
}
if strings.Contains(v.Message, secret) || strings.Contains(v.Context, secret) {
t.Fatal("verdict leaked the secret value")
}
if !strings.Contains(v.Context, "/cx-devassist-secrets") {
t.Errorf("expected Gemini skill in context, got %q", v.Context)
}
if len(tel.calls) != 2 {
t.Fatalf("expected 2 telemetry calls (detect + remediate), got %d", len(tel.calls))
}
if tel.calls[0].Type != "hooks-detect" || tel.calls[0].Engine != "Secrets" {

Check failure on line 459 in internal/commands/agenthooks/cx/hooks_test.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

string `Secrets` has 2 occurrences, but such constant `engineSecrets` already exists (goconst)
t.Errorf("detect telemetry = Type %q Engine %q", tel.calls[0].Type, tel.calls[0].Engine)
}
if tel.calls[1].Type != telemetryTypeHooksRemediate || tel.calls[1].Engine != "Secrets" {
t.Errorf("remediate telemetry = Type %q Engine %q", tel.calls[1].Type, tel.calls[1].Engine)
}
}

func TestCxBeforeFileEdit_CleanEdit_Accepts(t *testing.T) {
resetHookGlobals(t)
v := cxBeforeFileEdit(agenthooks.FileEditEvent{
Expand Down Expand Up @@ -555,6 +604,9 @@
if kicsScanner == nil {
t.Fatal("RegisterGuardrails should set kicsScanner")
}
if secretsScanner == nil {
t.Fatal("RegisterGuardrails should set secretsScanner")
}
if telemetryWrapper == nil {
t.Fatal("RegisterGuardrails should set telemetryWrapper")
}
Expand All @@ -566,6 +618,9 @@
if kicsScanner != nil {
t.Fatal("RegisterPassThrough should clear kicsScanner")
}
if secretsScanner != nil {
t.Fatal("RegisterPassThrough should clear secretsScanner")
}
}

func TestLogRemediationTelemetry(t *testing.T) {
Expand Down Expand Up @@ -746,10 +801,11 @@

func TestCxBeforeFileEdit_UnsupportedFileType_Accepts(t *testing.T) {
setEmptyHomeDir(t)
prevSca, prevKics := scaScanner, kicsScanner
defer func() { scaScanner, kicsScanner = prevSca, prevKics }()
prevSca, prevKics, prevSecrets := scaScanner, kicsScanner, secretsScanner
defer func() { scaScanner, kicsScanner, secretsScanner = prevSca, prevKics, prevSecrets }()
scaScanner = nil
kicsScanner = nil
secretsScanner = nil

path := filepath.Join(t.TempDir(), "notes.txt")
ev := agenthooks.FileEditEvent{
Expand All @@ -769,27 +825,31 @@
}

func TestRegisterGuardrails_SetsScanners(t *testing.T) {
prevSca, prevKics, prevTelemetry := scaScanner, kicsScanner, telemetryWrapper
defer func() { scaScanner, kicsScanner, telemetryWrapper = prevSca, prevKics, prevTelemetry }()
prevSca, prevKics, prevSecrets, prevTelemetry := scaScanner, kicsScanner, secretsScanner, telemetryWrapper
defer func() {
scaScanner, kicsScanner, secretsScanner, telemetryWrapper = prevSca, prevKics, prevSecrets, prevTelemetry
}()

telemetry := mock.TelemetryMockWrapper{}
RegisterGuardrails(&mock.JWTMockWrapper{}, &mock.FeatureFlagsMockWrapper{}, &mock.RealtimeScannerMockWrapper{}, telemetry)

assert.NotNil(t, scaScanner)
assert.NotNil(t, kicsScanner)
assert.NotNil(t, secretsScanner)
assert.Equal(t, telemetry, telemetryWrapper)
}

func TestRegisterPassThrough_ClearsScanners(t *testing.T) {
prevSca, prevKics := scaScanner, kicsScanner
defer func() { scaScanner, kicsScanner = prevSca, prevKics }()
prevSca, prevKics, prevSecrets := scaScanner, kicsScanner, secretsScanner
defer func() { scaScanner, kicsScanner, secretsScanner = prevSca, prevKics, prevSecrets }()

RegisterGuardrails(&mock.JWTMockWrapper{}, &mock.FeatureFlagsMockWrapper{}, &mock.RealtimeScannerMockWrapper{}, mock.TelemetryMockWrapper{})
assert.NotNil(t, scaScanner)

RegisterPassThrough()
assert.Nil(t, scaScanner)
assert.Nil(t, kicsScanner)
assert.Nil(t, secretsScanner)
}

func TestLogRemediationTelemetry_NilWrapper_NoOp(t *testing.T) {
Expand Down Expand Up @@ -873,6 +933,7 @@
}}, nil
})
scaScanner = nil
secretsScanner = nil

patch := "*** Begin Patch\n" +
"*** Add File: main.tf\n" +
Expand Down
40 changes: 40 additions & 0 deletions internal/commands/agenthooks/guardrails/secrets/content.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package secrets

import (
"os"
"strings"

agenthooks "github.com/Checkmarx/ast-cx-hooks"
)

func normLF(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.ReplaceAll(s, "\r", "\n")
}

// proposedContent returns the file content that would exist after changes are applied,
// plus the content currently on disk (empty when the file does not exist yet).
// ok is false when Before text is missing or matches more than once.
func proposedContent(filePath string, changes []agenthooks.FileDiff) (newContent, originalContent string, ok bool) {
diskBytes, readErr := os.ReadFile(filePath)
if readErr == nil {
originalContent = string(diskBytes)
}

if len(changes) == 1 && changes[0].Before == "" {
return changes[0].After, originalContent, true
}

current := normLF(originalContent)
for _, diff := range changes {
before := normLF(diff.Before)
after := normLF(diff.After)
if strings.Count(current, before) != 1 {
return "", normLF(originalContent), false
}
idx := strings.Index(current, before)
current = current[:idx] + after + current[idx+len(before):]
}

return current, normLF(originalContent), true
}
77 changes: 77 additions & 0 deletions internal/commands/agenthooks/guardrails/secrets/content_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//go:build !integration

package secrets

import (
"os"
"path/filepath"
"testing"

agenthooks "github.com/Checkmarx/ast-cx-hooks"
)

const (
testConfigEnv = "config.env"
debugFalse = "DEBUG=false"
debugTrue = "DEBUG=true"
placeholderKey = "API_KEY=placeholder"
)

func TestProposedContent_FullFileWrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "new.env")
changes := []agenthooks.FileDiff{{Before: "", After: "TOKEN=abc"}}
newContent, originalContent, ok := proposedContent(path, changes)
if !ok {
t.Fatal("full-file write should always resolve")
}
if newContent != "TOKEN=abc" || originalContent != "" {
t.Fatalf("newContent=%q originalContent=%q", newContent, originalContent)
}
}

func TestProposedContent_UniqueMatch_Applies(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, testConfigEnv)
if err := os.WriteFile(path, []byte(debugFalse), 0o600); err != nil {
t.Fatal(err)
}
changes := []agenthooks.FileDiff{{Before: debugFalse, After: debugTrue}}
newContent, _, ok := proposedContent(path, changes)
if !ok {
t.Fatal("unique match should resolve")
}
if newContent != debugTrue {
t.Fatalf("newContent = %q, want %s", newContent, debugTrue)
}
}

func TestProposedContent_DuplicateBeforeText_IsUnresolved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, testConfigEnv)
body := placeholderKey + "\n" + debugTrue + "\n" + placeholderKey
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
changes := []agenthooks.FileDiff{{Before: placeholderKey, After: "API_KEY=sk-live-123"}}
_, _, ok := proposedContent(path, changes)
if ok {
t.Fatal("a Before string that matches more than once must not be guessed at")
}
}

func TestProposedContent_BeforeTextMissing_IsUnresolved(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, testConfigEnv)
if err := os.WriteFile(path, []byte(debugTrue), 0o600); err != nil {
t.Fatal(err)
}
changes := []agenthooks.FileDiff{{Before: "STALE_TEXT", After: "TOKEN=sk-live-123"}}
newContent, _, ok := proposedContent(path, changes)
if ok {
t.Fatal("a Before string that is not found must not be silently skipped")
}
if newContent != "" {
t.Fatalf("newContent should be empty when unresolved, got %q", newContent)
}
}
Loading
Loading