diff --git a/internal/commands/agenthooks/cx/hooks.go b/internal/commands/agenthooks/cx/hooks.go index 1c85fbc2..9fb7e331 100644 --- a/internal/commands/agenthooks/cx/hooks.go +++ b/internal/commands/agenthooks/cx/hooks.go @@ -13,6 +13,7 @@ import ( "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/scannerconfig" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/sessiontally" "github.com/checkmarx/ast-cli/internal/wrappers" ) @@ -35,6 +36,17 @@ var scaScanner *sca.Scanner // It is set by RegisterGuardrails and cleared by RegisterPassThrough. var kicsScanner *kics.Scanner +// bindThreshold points each guardrail's finding filter at the severity threshold for this event's +// agent, so findings below cx_severity_threshold are left out of a deny and the agent is not told to +// fix them. Agents the switches do not apply to keep every finding. +func bindThreshold(agent agenthooks.AgentID) { + below := func(severity string) bool { return belowThreshold(agent, severity) } + secrets.BelowThreshold = below + asca.BelowThreshold = below + kics.BelowThreshold = below + sca.BelowThreshold = below +} + // 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 @@ -59,6 +71,7 @@ func sessionIDFromToolCall(ev *agenthooks.ToolCallEvent) string { // cxBeforeToolCall gates shell execution against the organization's blacklist, // tool rules, and the SCA guardrail (malicious / vulnerable package installs). func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { + bindThreshold(ev.Agent) if !ev.IsShell() { return agenthooks.Allow() } @@ -69,10 +82,10 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { } return agenthooks.Deny(reason) } - if scaScanner != nil { + if scaScanner != nil && scannerOn(ev.Agent, scannerconfig.SCA) { agent := agentToString(ev.Agent) sid := sessionIDFromToolCall(&ev) - if finding, remediation, severity := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir, agent, sid); finding != "" { + if finding, remediation, severity := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir, agent, sid); finding != "" && !belowThreshold(ev.Agent, severity) { sessiontally.Add(sid, engineSca, 1, 1) logRemediationTelemetry(agent, "SCA", severity, sid) return agenthooks.DenyWithContext(finding, remediation) @@ -100,6 +113,7 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict { // or if the file exceeds the policy size cap. Reads do NOT count toward // the blast-radius budget (that limit is about writes). func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { + bindThreshold(ev.Agent) if ev.Agent == agenthooks.AgentCursor && len(ev.Changes) == 0 { if reason := guardrails.ScanFileForSecrets(ev.FilePath); reason != "" { return agenthooks.RejectEdit(reason) @@ -118,32 +132,39 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict { return agenthooks.RejectEdit(reason) } agent := agentToString(ev.Agent) - if blocked, reason, context, severity := asca.ScanFileEdit(&ev, telemetryWrapper, agent); blocked { - sessiontally.Add(ev.SessionID, engineAsca, 1, 1) - logRemediationTelemetry(agent, "Asca", severity, ev.SessionID) - return agenthooks.RejectEditWithContext(reason, context) + // Each engine below is skipped (not just its verdict ignored) when the developer switched it off in + // ~/.checkmarx/cx-onboarding.properties; an unreadable config leaves every engine on. + // cx_severity_threshold (same file): a deny whose highest severity is below the threshold is let + // through for ASCA, KICS, SCA and secrets. Malicious packages and an unknown severity + // are never relaxed. A blank or invalid threshold means low (nothing filtered). + if scannerOn(ev.Agent, scannerconfig.ASCA) { + if blocked, reason, context, severity := asca.ScanFileEdit(&ev, telemetryWrapper, agent); blocked && !belowThreshold(ev.Agent, severity) { + sessiontally.Add(ev.SessionID, engineAsca, 1, 1) + logRemediationTelemetry(agent, "Asca", severity, ev.SessionID) + return agenthooks.RejectEditWithContext(reason, context) + } } var kicsNote string - if kicsScanner != nil { + if kicsScanner != nil && scannerOn(ev.Agent, scannerconfig.KICS) { blocked, reason, context, note, severity := kics.ScanFileEdit(&ev, kicsScanner, telemetryWrapper, agent) - if blocked { + if blocked && !belowThreshold(ev.Agent, severity) { sessiontally.Add(ev.SessionID, engineKics, 1, 1) logRemediationTelemetry(agent, "IaC", severity, ev.SessionID) return agenthooks.RejectEditWithContext(reason, context) } kicsNote = note } - if scaScanner != nil { + if scaScanner != nil && scannerOn(ev.Agent, scannerconfig.SCA) { for _, diff := range ev.Changes { - if finding, remediation, severity := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir, agent, ev.SessionID); finding != "" { + if finding, remediation, severity := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir, agent, ev.SessionID); finding != "" && !belowThreshold(ev.Agent, severity) { sessiontally.Add(ev.SessionID, engineSca, 1, 1) logRemediationTelemetry(agent, "Oss", severity, ev.SessionID) return agenthooks.RejectEditWithContext(finding, remediation) } } } - if secretsScanner != nil { - if blocked, reason, context, severity := secrets.ScanFileEdit(&ev, secretsScanner, telemetryWrapper, agent); blocked { + if secretsScanner != nil && secretsApply(ev.Agent) && scannerOn(ev.Agent, scannerconfig.Secrets) { + if blocked, reason, context, severity := secrets.ScanFileEdit(&ev, secretsScanner, telemetryWrapper, agent); blocked && !belowThreshold(ev.Agent, severity) { sessiontally.Add(ev.SessionID, engineSecrets, 1, 1) logRemediationTelemetry(agent, "Secrets", severity, ev.SessionID) return agenthooks.RejectEditWithContext(reason, context) @@ -284,6 +305,36 @@ func logRemediationTelemetry(agent, engine, severity, sessionID string) { } } +// switchesApply reports whether the ~/.checkmarx/cx-onboarding.properties scanner switches and severity +// threshold are honoured for this agent. Claude, Cursor, Gemini and Codex are excluded for now: they keep +// scanning with ASCA / KICS / SCA and blocking at any severity even when the shared file exists on the +// machine. Every other agent (GitHub Copilot, Droid, Windsurf, ...) honours it. Add an agent to +// the case list below to hold it back, or remove one to roll the feature out to it. +func switchesApply(agent agenthooks.AgentID) bool { + switch agent { + case agenthooks.AgentClaude, agenthooks.AgentCursor, agenthooks.AgentGemini, agenthooks.AgentCodex: + return false + } + return true +} + +// secretsApply reports whether the secret-detection guardrail runs for this agent. Claude, Cursor, Gemini +// and Codex skip it entirely and keep the ASCA / KICS / SCA guardrails; every other agent runs it. It +// shares switchesApply's agent list so the two cannot drift apart. +func secretsApply(agent agenthooks.AgentID) bool { + return switchesApply(agent) +} + +// scannerOn is scannerconfig.Enabled, limited to the agents the switches apply to. +func scannerOn(agent agenthooks.AgentID, s scannerconfig.Scanner) bool { + return !switchesApply(agent) || scannerconfig.Enabled(s) +} + +// belowThreshold is scannerconfig.BelowThreshold, limited to the agents the switches apply to. +func belowThreshold(agent agenthooks.AgentID, severity string) bool { + return switchesApply(agent) && scannerconfig.BelowThreshold(severity) +} + // agentToString converts agenthooks.AgentID enum to string representation for telemetry. func agentToString(agent agenthooks.AgentID) string { switch agent { diff --git a/internal/commands/agenthooks/cx/hooks_test.go b/internal/commands/agenthooks/cx/hooks_test.go index 8e0a65b1..a70eeb8c 100644 --- a/internal/commands/agenthooks/cx/hooks_test.go +++ b/internal/commands/agenthooks/cx/hooks_test.go @@ -438,7 +438,7 @@ func TestCxBeforeFileEdit_SecretsFinding_RejectsWithContext(t *testing.T) { }) v := cxBeforeFileEdit(agenthooks.FileEditEvent{ - Agent: agenthooks.AgentGemini, + Agent: agenthooks.AgentCopilotCLI, SessionID: "sec-sess", FilePath: "config.env", WorkDir: t.TempDir(), @@ -450,8 +450,8 @@ func TestCxBeforeFileEdit_SecretsFinding_RejectsWithContext(t *testing.T) { 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 !strings.Contains(v.Context, "cx-devassist:cx-devassist-secrets") { + t.Errorf("expected Copilot 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)) diff --git a/internal/commands/agenthooks/cx/scanner_switch_test.go b/internal/commands/agenthooks/cx/scanner_switch_test.go new file mode 100644 index 00000000..81d10502 --- /dev/null +++ b/internal/commands/agenthooks/cx/scanner_switch_test.go @@ -0,0 +1,243 @@ +//go:build !integration + +package cx + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + agenthooks "github.com/Checkmarx/ast-cx-hooks" + + "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/services/realtimeengine" + "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" + "github.com/checkmarx/ast-cli/internal/services/realtimeengine/secretsrealtime" +) + +// useScannerConfig points the user's home at a temp dir holding ~/.checkmarx/cx-onboarding.properties +// with the given body (no file at all when body is empty). +func useScannerConfig(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + restore := setHomeDir(dir) + t.Cleanup(restore) + if body == "" { + return + } + cxDir := filepath.Join(dir, ".checkmarx") + if err := os.MkdirAll(cxDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cxDir, "cx-onboarding.properties"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func secretsEdit(secret string, agent agenthooks.AgentID) agenthooks.FileEditEvent { + return agenthooks.FileEditEvent{ + Agent: agent, + SessionID: "switch-sess", + FilePath: "config.env", + WorkDir: "", + Changes: []agenthooks.FileDiff{{Before: "", After: "TOKEN=" + secret}}, + } +} + +func stubSecretsScanner(secret string) *secrets.Scanner { + return secrets.NewScannerWithFunc(func(_, content, _ string) ([]secretsrealtime.SecretsRealtimeResult, error) { + if strings.Contains(content, secret) { + return []secretsrealtime.SecretsRealtimeResult{{ + Title: "github-pat", Description: "token", SecretValue: secret, Severity: "Critical", + Locations: []realtimeengine.Location{{Line: 1}}, + }}, nil + } + return nil, nil + }) +} + +func TestCxBeforeFileEdit_SecretsSwitch(t *testing.T) { + const secret = "ghp_switch_test_secret_value" + cases := []struct { + name string + config string + wantBlock bool + }{ + {"no config file is enabled", "", true}, + {"explicit true is enabled", "cx_secret_scanner_enabled=true\n", true}, + {"typo stays enabled", "cx_secret_scanner_enabled=flase\n", true}, + {"other scanner off does not affect secrets", "cx_asca_scanner_enabled=false\ncx_kics_scanner_enabled=false\n", true}, + {"secrets off is skipped", "cx_secret_scanner_enabled=false\n", false}, + {"secrets off (CRLF, caps)", "cx_secret_scanner_enabled=OFF\r\n", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, tc.config) + secretsScanner = stubSecretsScanner(secret) + v := cxBeforeFileEdit(secretsEdit(secret, agenthooks.AgentCopilotCLI)) + if v.Permit == tc.wantBlock { + t.Fatalf("Permit=%v, wantBlock=%v", v.Permit, tc.wantBlock) + } + }) + } +} + +func kicsFinding(severity string) *kics.Scanner { + return kics.NewScannerWithFunc(func(string, string) ([]iacrealtime.IacRealtimeResult, error) { + return []iacrealtime.IacRealtimeResult{{ + Title: "Privileged Container", SimilarityID: "sim1", Severity: severity, + Description: "Container runs as privileged", Locations: []realtimeengine.Location{{Line: 5}}, + }}, nil + }) +} + +func kicsEdit(agent agenthooks.AgentID) agenthooks.FileEditEvent { + return agenthooks.FileEditEvent{ + Agent: agent, SessionID: "thr-sess", FilePath: "/project/Dockerfile", + Changes: []agenthooks.FileDiff{{Before: "", After: "FROM ubuntu\nUSER root\n"}}, + } +} + +func TestCxBeforeFileEdit_SeverityThreshold(t *testing.T) { + cases := []struct { + name string + config string + severity string + wantBlock bool + }{ + {"no config blocks low", "", "LOW", true}, + {"blank threshold blocks low", "cx_severity_threshold=\n", "LOW", true}, + {"invalid threshold blocks low", "cx_severity_threshold=hihg\n", "LOW", true}, + {"high threshold lets low through", "cx_severity_threshold=high\n", "LOW", false}, + {"high threshold lets medium through", "cx_severity_threshold=high\n", "MEDIUM", false}, + {"high threshold blocks high", "cx_severity_threshold=high\n", "HIGH", true}, + {"high threshold blocks critical", "cx_severity_threshold=high\n", "CRITICAL", true}, + {"unknown severity is never relaxed", "cx_severity_threshold=critical\n", "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, tc.config) + kicsScanner = kicsFinding(tc.severity) + if v := cxBeforeFileEdit(kicsEdit(agenthooks.AgentCopilotCLI)); v.Permit == tc.wantBlock { + t.Fatalf("Permit=%v, wantBlock=%v", v.Permit, tc.wantBlock) + } + }) + } +} + +func TestCxBeforeFileEdit_SeverityThreshold_Secrets(t *testing.T) { + const secret = "ghp_threshold_test_secret_value" + cases := []struct { + name string + config string + severity string + wantBlock bool + }{ + {"no config blocks low", "", "Low", true}, + {"high threshold lets medium through", "cx_severity_threshold=high\n", "Medium", false}, + {"high threshold blocks high", "cx_severity_threshold=high\n", "High", true}, + {"critical threshold lets low through", "cx_severity_threshold=critical\n", "Low", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, tc.config) + secretsScanner = secrets.NewScannerWithFunc(func(_, content, _ string) ([]secretsrealtime.SecretsRealtimeResult, error) { + if strings.Contains(content, secret) { + return []secretsrealtime.SecretsRealtimeResult{{ + Title: "generic-key", Description: "key", SecretValue: secret, Severity: tc.severity, + Locations: []realtimeengine.Location{{Line: 1}}, + }}, nil + } + return nil, nil + }) + if v := cxBeforeFileEdit(secretsEdit(secret, agenthooks.AgentCopilotCLI)); v.Permit == tc.wantBlock { + t.Fatalf("Permit=%v, wantBlock=%v", v.Permit, tc.wantBlock) + } + }) + } +} + +func TestCxBeforeFileEdit_SeverityThreshold_SecretsMixed(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, "cx_severity_threshold=high\n") + secretsScanner = secrets.NewScannerWithFunc(func(_, content, _ string) ([]secretsrealtime.SecretsRealtimeResult, error) { + return []secretsrealtime.SecretsRealtimeResult{ + {Title: "high-rule", Description: "d", SecretValue: "hv", Severity: "High", Locations: []realtimeengine.Location{{Line: 1}}}, + {Title: "medium-rule", Description: "d", SecretValue: "mv", Severity: "Medium", Locations: []realtimeengine.Location{{Line: 2}}}, + }, nil + }) + v := cxBeforeFileEdit(secretsEdit("x", agenthooks.AgentCopilotCLI)) + if v.Permit { + t.Fatal("the high secret must block") + } + if blob := fmt.Sprintf("%+v", v); !strings.Contains(blob, "high-rule") || strings.Contains(blob, "medium-rule") { + t.Fatalf("deny must list the high finding and omit the medium one: %s", blob) + } +} + +// The switches and secret detection are features of every agent except Claude, Cursor, Gemini and Codex: those four skip secret +// detection and, with the shared properties file present, keeps ASCA/KICS/SCA blocking at any severity. +func TestSwitches_IgnoredForOtherAgents(t *testing.T) { + const secret = "ghp_other_agent_secret_value" + others := []agenthooks.AgentID{ + agenthooks.AgentClaude, agenthooks.AgentCursor, agenthooks.AgentGemini, agenthooks.AgentCodex, + } + for _, agent := range others { + t.Run(agentToString(agent)+"/secrets are skipped", func(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, "") + secretsScanner = stubSecretsScanner(secret) + if v := cxBeforeFileEdit(secretsEdit(secret, agent)); !v.Permit { + t.Fatal("secret detection must not run for this agent") + } + }) + t.Run(agentToString(agent)+"/kics switch off still blocks", func(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, "cx_kics_scanner_enabled=false\n") + kicsScanner = kicsFinding("HIGH") + if v := cxBeforeFileEdit(kicsEdit(agent)); v.Permit { + t.Fatal("kics switch must not apply to this agent") + } + }) + t.Run(agentToString(agent)+"/threshold does not relax", func(t *testing.T) { + resetHookGlobals(t) + useScannerConfig(t, "cx_severity_threshold=critical\n") + kicsScanner = kicsFinding("LOW") + if v := cxBeforeFileEdit(kicsEdit(agent)); v.Permit { + t.Fatal("severity threshold must not apply to this agent") + } + }) + } +} + +func TestSwitchesApply_ExcludesFourAgents(t *testing.T) { + for agent, want := range map[agenthooks.AgentID]bool{ + agenthooks.AgentCopilotCLI: true, agenthooks.AgentCopilot: true, + agenthooks.AgentDroid: true, agenthooks.AgentWindsurf: true, + agenthooks.AgentClaude: false, agenthooks.AgentCursor: false, agenthooks.AgentGemini: false, + agenthooks.AgentCodex: false, + } { + if got := switchesApply(agent); got != want { + t.Errorf("%s: got %v, want %v", agentToString(agent), got, want) + } + } +} + +func TestSecretsApply_ExcludesFourAgents(t *testing.T) { + for agent, want := range map[agenthooks.AgentID]bool{ + agenthooks.AgentCopilotCLI: true, agenthooks.AgentCopilot: true, + agenthooks.AgentDroid: true, agenthooks.AgentWindsurf: true, + agenthooks.AgentClaude: false, agenthooks.AgentCursor: false, + agenthooks.AgentGemini: false, agenthooks.AgentCodex: false, + } { + if got := secretsApply(agent); got != want { + t.Errorf("%s: got %v, want %v", agentToString(agent), got, want) + } + } +} diff --git a/internal/commands/agenthooks/guardrails/asca/asca.go b/internal/commands/agenthooks/guardrails/asca/asca.go index eed2bc7e..256890c2 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca.go +++ b/internal/commands/agenthooks/guardrails/asca/asca.go @@ -28,6 +28,11 @@ func isSupportedByASCA(filePath string) bool { return ok } +// BelowThreshold, when set by the hook dispatcher, reports whether a finding's severity is under the +// developer's cx_severity_threshold. Such findings are dropped from the deny, so the agent is never +// told to remediate them. Nil (the default) keeps every finding. +var BelowThreshold func(severity string) bool + // ScanFileEdit runs ASCA on the proposed post-edit content. // Returns blocked=true with a formatted reason, remediation context, and highest severity when ASCA // finds *new* vulnerabilities introduced by ev.Changes (delta-detection for edits; @@ -90,14 +95,16 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, telemetryWrapper wrappers.Teleme } // For new files (no original content), every finding is new - if originalContent == "" { - r, c := formatFindings(ev.FilePath, newResult.ScanDetails, ev.WorkDir, agent, ev.SessionID) - findingCount = len(newResult.ScanDetails) - return true, r, c, highestSeverity(newResult.ScanDetails) + newFindings := newResult.ScanDetails + if originalContent != "" { + var err error + newFindings, err = deltaFindings(ev, originalContent, ascaParams, wrapperParams, newResult.ScanDetails) + if err != nil { + newFindings = nil + } } - - newFindings, err := deltaFindings(ev, originalContent, ascaParams, wrapperParams, newResult.ScanDetails) - if err != nil || len(newFindings) == 0 { + newFindings = aboveThreshold(newFindings) + if len(newFindings) == 0 { findingCount = 0 return false, "", "", "" } @@ -130,6 +137,20 @@ func deltaFindings(ev *agenthooks.FileEditEvent, originalContent string, ascaPar return NewFindings(origDetails, newDetails), nil } +// aboveThreshold drops findings below the developer's severity threshold. +func aboveThreshold(findings []grpcs.ScanDetail) []grpcs.ScanDetail { + if BelowThreshold == nil { + return findings + } + kept := make([]grpcs.ScanDetail, 0, len(findings)) + for i := range findings { + if !BelowThreshold(findings[i].Severity) { + kept = append(kept, findings[i]) + } + } + return kept +} + // highestSeverity returns the highest severity level across the given ASCA findings. // Order: Critical > High > Medium > Low > (anything else). func highestSeverity(findings []grpcs.ScanDetail) string { diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index 0b98adbc..282c1242 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -40,6 +40,11 @@ func isSupportedByKICS(filePath string) bool { return false } +// BelowThreshold, when set by the hook dispatcher, reports whether a finding's severity is under the +// developer's cx_severity_threshold. Such findings are dropped from the deny, so the agent is never +// told to remediate them. Nil (the default) keeps every finding. +var BelowThreshold func(severity string) bool + // ScanFileEdit runs KICS on the proposed post-edit content. // Returns blocked=true with a formatted reason, remediation context, and highest severity when KICS // finds *new* vulnerabilities introduced by ev.Changes (delta-detection for edits; @@ -94,6 +99,10 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, svc *Scanner, telemetryWrapper w // For new files (no original content), every finding is new if originalContent == "" { + newResults = aboveThreshold(newResults) + if len(newResults) == 0 { + return false, "", "", "", "" + } r, c := formatFindings(ev.FilePath, newResults, ev.Agent, ev.WorkDir, ev.SessionID) findingCount = len(newResults) return true, r, c, "", highestSeverity(newResults) @@ -113,7 +122,7 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, svc *Scanner, telemetryWrapper w return false, "", "", skippedNote(ev.FilePath, err), "" } - newFindings := NewFindings(origResults, newResults) + newFindings := aboveThreshold(NewFindings(origResults, newResults)) if len(newFindings) == 0 { return false, "", "", "", "" } @@ -123,6 +132,20 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, svc *Scanner, telemetryWrapper w return true, r, c, "", highestSeverity(newFindings) } +// aboveThreshold drops findings below the developer's severity threshold. +func aboveThreshold(findings []iacrealtime.IacRealtimeResult) []iacrealtime.IacRealtimeResult { + if BelowThreshold == nil { + return findings + } + kept := make([]iacrealtime.IacRealtimeResult, 0, len(findings)) + for i := range findings { + if !BelowThreshold(findings[i].Severity) { + kept = append(kept, findings[i]) + } + } + return kept +} + // highestSeverity returns the highest severity level across the given KICS findings. // Order: Critical > High > Medium > Low > (anything else). func highestSeverity(findings []iacrealtime.IacRealtimeResult) string { diff --git a/internal/commands/agenthooks/guardrails/secrets/secrets.go b/internal/commands/agenthooks/guardrails/secrets/secrets.go index b0dffcca..24b413fe 100644 --- a/internal/commands/agenthooks/guardrails/secrets/secrets.go +++ b/internal/commands/agenthooks/guardrails/secrets/secrets.go @@ -18,6 +18,11 @@ const ( "edit can be scanned and applied." ) +// BelowThreshold, when set by the hook dispatcher, reports whether a finding's severity is under the +// developer's cx_severity_threshold. Such findings are dropped from the deny, so the agent is never +// told to remediate them. Nil (the default) keeps every finding. +var BelowThreshold func(severity string) bool + // ScanFileEdit runs secret detection on the proposed post-edit content. // Returns blocked=true with a formatted reason, remediation context, and highest severity when the // edit introduces a secret (delta-detection for edits; any secret for new writes). Findings the @@ -73,6 +78,19 @@ func ScanFileEdit(ev *agenthooks.FileEditEvent, svc *Scanner, telemetryWrapper w } } + if BelowThreshold != nil { + kept := make([]secretsrealtime.SecretsRealtimeResult, 0, len(findings)) + for i := range findings { + if !BelowThreshold(findings[i].Severity) { + kept = append(kept, findings[i]) + } + } + if len(kept) == 0 { + return false, "", "", "" + } + findings = kept + } + r, c := formatFindings(ev.FilePath, findings, ev.Agent, ev.WorkDir, ev.SessionID) findingCount = len(findings) return true, r, c, highestSeverity(findings) diff --git a/internal/commands/agenthooks/sca/sca.go b/internal/commands/agenthooks/sca/sca.go index 43785fab..88415104 100644 --- a/internal/commands/agenthooks/sca/sca.go +++ b/internal/commands/agenthooks/sca/sca.go @@ -6,6 +6,11 @@ import ( "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" ) +// BelowThreshold, when set by the hook dispatcher, reports whether a finding's severity is under the +// developer's cx_severity_threshold. Such findings are dropped from the deny, so the agent is never +// told to remediate them. Nil (the default) keeps every finding. +var BelowThreshold func(severity string) bool + // CheckBashInstall is the entry point for the pre-Bash-tool guardrail. It // returns ("", "", "") to allow the command, or (finding, remediation, severity) to block. // Errors fail open — we never block on infrastructure failures. @@ -73,6 +78,7 @@ func denyFrom(malicious, vulnerable []ossrealtime.OssPackage, workDir, agent, se f, r := DenyMalicious(malicious, agent) return tag + " " + f, tag + " " + r, statusMalicious } + vulnerable = aboveThreshold(vulnerable) if len(vulnerable) > 0 { f, r := DenyVulnerable(vulnerable, workDir, agent, sessionID) return tag + " " + f, tag + " " + r, highestVulnSeverity(vulnerable) @@ -80,6 +86,28 @@ func denyFrom(malicious, vulnerable []ossrealtime.OssPackage, workDir, agent, se return "", "", "" } +// aboveThreshold drops CVEs below the developer's severity threshold, and any package left with +// none. Malicious packages never go through here. A CVE with an unknown severity is kept. +func aboveThreshold(pkgs []ossrealtime.OssPackage) []ossrealtime.OssPackage { + if BelowThreshold == nil { + return pkgs + } + kept := make([]ossrealtime.OssPackage, 0, len(pkgs)) + for _, p := range pkgs { + vulns := make([]ossrealtime.Vulnerability, 0, len(p.Vulnerabilities)) + for _, v := range p.Vulnerabilities { + if !BelowThreshold(v.Severity) { + vulns = append(vulns, v) + } + } + if len(vulns) > 0 { + p.Vulnerabilities = vulns + kept = append(kept, p) + } + } + return kept +} + // highestVulnSeverity returns the highest CVE severity across all vulnerable packages. func highestVulnSeverity(pkgs []ossrealtime.OssPackage) string { rank := map[string]int{"Critical": 4, "High": 3, "Medium": 2, "Low": 1} diff --git a/internal/commands/agenthooks/sca/threshold_test.go b/internal/commands/agenthooks/sca/threshold_test.go new file mode 100644 index 00000000..320a4552 --- /dev/null +++ b/internal/commands/agenthooks/sca/threshold_test.go @@ -0,0 +1,42 @@ +//go:build !integration + +package sca + +import ( + "testing" + + "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" +) + +func TestAboveThreshold_DropsLowCVEsAndEmptyPackages(t *testing.T) { + old := BelowThreshold + t.Cleanup(func() { BelowThreshold = old }) + BelowThreshold = func(sev string) bool { return sev == "Low" || sev == "Medium" } + + pkgs := []ossrealtime.OssPackage{ + {PackageName: "mixed", Vulnerabilities: []ossrealtime.Vulnerability{{CVE: "CVE-1", Severity: "High"}, {CVE: "CVE-2", Severity: "Medium"}}}, + {PackageName: "lowonly", Vulnerabilities: []ossrealtime.Vulnerability{{CVE: "CVE-3", Severity: "Low"}}}, + {PackageName: "unknown", Vulnerabilities: []ossrealtime.Vulnerability{{CVE: "CVE-4", Severity: ""}}}, + } + got := aboveThreshold(pkgs) + if len(got) != 2 || got[0].PackageName != "mixed" || got[1].PackageName != "unknown" { + t.Fatalf("unexpected packages: %+v", got) + } + if len(got[0].Vulnerabilities) != 1 || got[0].Vulnerabilities[0].CVE != "CVE-1" { + t.Fatalf("mixed package must keep only its High CVE: %+v", got[0].Vulnerabilities) + } + if len(pkgs[0].Vulnerabilities) != 2 { + t.Fatal("input must not be mutated") + } +} + +func TestDenyFrom_MaliciousIgnoresThreshold(t *testing.T) { + old := BelowThreshold + t.Cleanup(func() { BelowThreshold = old }) + BelowThreshold = func(string) bool { return true } + + mal := []ossrealtime.OssPackage{{PackageName: "evil", Status: statusMalicious}} + if f, _, sev := denyFrom(mal, nil, "", "Copilot", "s"); f == "" || sev != statusMalicious { + t.Fatalf("malicious must always deny, got finding=%q severity=%q", f, sev) + } +} diff --git a/internal/commands/agenthooks/scannerconfig/scannerconfig.go b/internal/commands/agenthooks/scannerconfig/scannerconfig.go new file mode 100644 index 00000000..7d88848a --- /dev/null +++ b/internal/commands/agenthooks/scannerconfig/scannerconfig.go @@ -0,0 +1,117 @@ +// Package scannerconfig reads the per-scanner on/off switches a developer sets in +// ~/.checkmarx/cx-onboarding.properties (seeded by the agent plugins). Every scanner is ENABLED +// unless its key is explicitly set to a false-like value, and any read problem also means enabled: +// the switches can only narrow what the hooks do, never make them block. +package scannerconfig + +import ( + "bufio" + "io" + "os" + "path/filepath" + "strings" +) + +// Scanner identifies one guardrail engine. +type Scanner string + +const ( + ASCA Scanner = "cx_asca_scanner_enabled" + SCA Scanner = "cx_sca_scanner_enabled" + KICS Scanner = "cx_kics_scanner_enabled" + Secrets Scanner = "cx_secret_scanner_enabled" +) + +const ( + fileName = "cx-onboarding.properties" + // maxBytes mirrors the plugins' reader: an oversized file is treated as unreadable (all enabled). + maxBytes = 8192 + utf8BOM = "\xef\xbb\xbf" +) + +// Path returns ~/.checkmarx/cx-onboarding.properties, or "" when the home directory is unknown. +func Path() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".checkmarx", fileName) +} + +// Enabled reports whether the scanner is switched on in the user's config file. +func Enabled(s Scanner) bool { + return enabledIn(Path(), s) +} + +func enabledIn(path string, s Scanner) bool { + value, found := valueIn(path, string(s)) + return !found || !isFalse(value) +} + +// valueIn returns the last `key=value` for key in the file, or found=false when the file is missing, +// unreadable, oversized or does not set the key. Same parsing rules as the plugins' readers. +func valueIn(path, key string) (value string, found bool) { + if path == "" { + return "", false + } + f, err := os.Open(path) + if err != nil { + return "", false + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) + if err != nil || len(raw) > maxBytes { + return "", false + } + sc := bufio.NewScanner(strings.NewReader(strings.TrimPrefix(string(raw), utf8BOM))) + for sc.Scan() { // last occurrence wins + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(k) != key { + continue + } + value, found = v, true + } + return value, found +} + +func isFalse(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "false", "0", "no", "off", "disabled": + return true + } + return false +} + +const thresholdKey = "cx_severity_threshold" + +// severityRank orders the severities the guardrails report. Anything not listed (an empty string, a +// package status such as "Malicious", an unexpected label) has no rank and is never relaxed. +var severityRank = map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4} + +// Threshold returns the minimum severity rank (1 low … 4 critical) a finding needs to block. +// Blank, missing or invalid means low, so nothing is filtered. +func Threshold() int { + return thresholdIn(Path()) +} + +func thresholdIn(path string) int { + value, found := valueIn(path, thresholdKey) + if !found { + return 1 + } + if rank, ok := severityRank[strings.ToLower(strings.TrimSpace(value))]; ok { + return rank + } + return 1 +} + +// BelowThreshold reports whether a deny whose highest severity is `severity` should be let through +// because it is under cx_severity_threshold. An unranked severity returns false (keep blocking). +func BelowThreshold(severity string) bool { + rank, ok := severityRank[strings.ToLower(strings.TrimSpace(severity))] + return ok && rank < Threshold() +} diff --git a/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go b/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go new file mode 100644 index 00000000..3353059f --- /dev/null +++ b/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go @@ -0,0 +1,138 @@ +//go:build !integration + +package scannerconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func write(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), fileName) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestEnabledDefaults(t *testing.T) { + if !enabledIn("", ASCA) { + t.Error("empty path must mean enabled") + } + if !enabledIn(filepath.Join(t.TempDir(), "missing"), SCA) { + t.Error("missing file must mean enabled") + } + if !enabledIn(write(t, "# nothing set\n"), KICS) { + t.Error("absent key must mean enabled") + } +} + +func TestEnabledFalseValues(t *testing.T) { + for _, v := range []string{"false", "FALSE", "0", "no", "off", "Disabled", " false "} { + if enabledIn(write(t, "cx_asca_scanner_enabled="+v+"\n"), ASCA) { + t.Errorf("%q should disable", v) + } + } +} + +func TestEnabledGarbageStaysOn(t *testing.T) { + for _, v := range []string{"flase", "", "banana", "2", "true"} { + if !enabledIn(write(t, "cx_kics_scanner_enabled="+v+"\n"), KICS) { + t.Errorf("%q must stay enabled", v) + } + } +} + +func TestKeysAreIndependent(t *testing.T) { + p := write(t, "cx_sca_scanner_enabled=false\ncx_secret_scanner_enabled=true\n") + if enabledIn(p, SCA) || !enabledIn(p, ASCA) || !enabledIn(p, KICS) || !enabledIn(p, Secrets) { + t.Error("only SCA should be disabled") + } +} + +func TestCommentedLineIgnoredAndLastWins(t *testing.T) { + if !enabledIn(write(t, "# cx_asca_scanner_enabled=false\n"), ASCA) { + t.Error("commented key must be ignored") + } + if !enabledIn(write(t, "cx_asca_scanner_enabled=false\ncx_asca_scanner_enabled=true\n"), ASCA) { + t.Error("last occurrence should win") + } +} + +func TestBOMAndCRLF(t *testing.T) { + p := write(t, "\xef\xbb\xbfcx_secret_scanner_enabled=false\r\ncx_asca_scanner_enabled=true\r\n") + if enabledIn(p, Secrets) || !enabledIn(p, ASCA) { + t.Error("BOM/CRLF file misparsed") + } +} + +func TestOversizedFailsOpen(t *testing.T) { + p := write(t, strings.Repeat("# pad\n", 5000)+"cx_asca_scanner_enabled=false\n") + if !enabledIn(p, ASCA) { + t.Error("oversized file must fail open to enabled") + } +} + +func TestPathUsesHome(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + if got := Path(); got != filepath.Join(dir, ".checkmarx", fileName) { + t.Errorf("unexpected path %q", got) + } + _ = os.MkdirAll(filepath.Join(dir, ".checkmarx"), 0o755) + _ = os.WriteFile(Path(), []byte("cx_kics_scanner_enabled=off\n"), 0o600) + if Enabled(KICS) || !Enabled(ASCA) { + t.Error("Enabled should read the file under the user's home") + } +} + +func TestThresholdIn(t *testing.T) { + cases := map[string]int{ + "": 1, // no file content + "cx_severity_threshold=\n": 1, // blank + "cx_severity_threshold=low\n": 1, + "cx_severity_threshold=Medium\n": 2, + "cx_severity_threshold= HIGH \n": 3, + "cx_severity_threshold=critical\n": 4, + "cx_severity_threshold=hihg\n": 1, // invalid + "# cx_severity_threshold=high\n": 1, // commented + "cx_severity_threshold=high\ncx_severity_threshold=low\n": 1, // last wins + } + for body, want := range cases { + if got := thresholdIn(write(t, body)); got != want { + t.Errorf("%q: got %d, want %d", body, got, want) + } + } + if thresholdIn(filepath.Join(t.TempDir(), "missing")) != 1 || thresholdIn("") != 1 { + t.Error("missing file / path must mean low") + } +} + +func TestBelowThreshold(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + _ = os.MkdirAll(filepath.Join(dir, ".checkmarx"), 0o755) + set := func(body string) { + if err := os.WriteFile(Path(), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + set("cx_severity_threshold=high\n") + for sev, want := range map[string]bool{ + "Low": true, "MEDIUM": true, "High": false, "Critical": false, + "": false, "Malicious": false, "weird": false, // unranked is never relaxed + } { + if got := BelowThreshold(sev); got != want { + t.Errorf("high threshold, %q: got %v, want %v", sev, got, want) + } + } + set("cx_severity_threshold=\n") + if BelowThreshold("Low") { + t.Error("blank threshold must filter nothing") + } +}