Skip to content
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 @@ import (

"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 (r *recordingTelemetry) SendAIDataToLog(data *wrappers.DataForAITelemetry)

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_SCAManifest_RejectsWithContext(t *testing.T) {
}
}

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 != engineSecrets {
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 @@ func TestRegisterGuardrails_AndPassThrough(t *testing.T) {
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 @@ func TestRegisterGuardrails_AndPassThrough(t *testing.T) {
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_CursorRead_NoSecrets_Accepts(t *testing.T) {

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 TestCxBeforePrompt_Benign_Accepts(t *testing.T) {
}

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 @@ func TestCodexApplyPatch_KICSFinding_DeniesEndToEnd(t *testing.T) {
}}, 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