diff --git a/UITests/UITests.csproj b/UITests/UITests.csproj index 0abeb2da..cf612ecf 100644 --- a/UITests/UITests.csproj +++ b/UITests/UITests.csproj @@ -1,4 +1,4 @@ - + @@ -75,8 +75,8 @@ ..\packages\System.Diagnostics.PerformanceCounter.5.0.1\lib\net461\System.Diagnostics.PerformanceCounter.dll - - ..\packages\System.Drawing.Common.5.0.2\lib\net461\System.Drawing.Common.dll + + ..\packages\System.Drawing.Common.9.0.0\lib\net462\System.Drawing.Common.dll diff --git a/ast-visual-studio-extension-tests/ast-visual-studio-extension-tests.csproj b/ast-visual-studio-extension-tests/ast-visual-studio-extension-tests.csproj index e8a97f52..318b4a94 100644 --- a/ast-visual-studio-extension-tests/ast-visual-studio-extension-tests.csproj +++ b/ast-visual-studio-extension-tests/ast-visual-studio-extension-tests.csproj @@ -8,9 +8,11 @@ + + diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/AssistIconLoaderTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/AssistIconLoaderTests.cs new file mode 100644 index 00000000..be0f4c70 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/AssistIconLoaderTests.cs @@ -0,0 +1,103 @@ +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for AssistIconLoader (severity icon file names and base names; no VS/theme required). + /// + public class AssistIconLoaderTests + { + #region IconsBasePath + + [Fact] + public void IconsBasePath_IsExpected() + { + Assert.Equal("CxExtension/Resources/CxAssist/Icons", AssistIconLoader.IconsBasePath); + } + + #endregion + + #region GetSeverityIconFileName + + [Theory] + [InlineData(SeverityLevel.Malicious, "malicious.png")] + [InlineData(SeverityLevel.Critical, "critical.png")] + [InlineData(SeverityLevel.High, "high.png")] + [InlineData(SeverityLevel.Medium, "medium.png")] + [InlineData(SeverityLevel.Low, "low.png")] + [InlineData(SeverityLevel.Info, "low.png")] + [InlineData(SeverityLevel.Ok, "ok.png")] + [InlineData(SeverityLevel.Unknown, "unknown.png")] + [InlineData(SeverityLevel.Ignored, "ignored.png")] + public void GetSeverityIconFileName_ReturnsExpectedFileName(SeverityLevel severity, string expected) + { + Assert.Equal(expected, AssistIconLoader.GetSeverityIconFileName(severity)); + } + + [Fact] + public void GetSeverityIconFileName_EndsWithPng() + { + Assert.EndsWith(".png", AssistIconLoader.GetSeverityIconFileName(SeverityLevel.Critical)); + } + + #endregion + + #region GetSeverityIconBaseName + + [Theory] + [InlineData("Malicious", "malicious")] + [InlineData("malicious", "malicious")] + [InlineData("MALICIOUS", "malicious")] + [InlineData("Critical", "critical")] + [InlineData("High", "high")] + [InlineData("Medium", "medium")] + [InlineData("Low", "low")] + [InlineData("Info", "low")] + [InlineData("Ok", "ok")] + [InlineData("Unknown", "unknown")] + [InlineData("Ignored", "ignored")] + public void GetSeverityIconBaseName_ReturnsExpectedBaseName(string severity, string expected) + { + Assert.Equal(expected, AssistIconLoader.GetSeverityIconBaseName(severity)); + } + + [Fact] + public void GetSeverityIconBaseName_Null_ReturnsUnknown() + { + Assert.Equal("unknown", AssistIconLoader.GetSeverityIconBaseName(null)); + } + + [Fact] + public void GetSeverityIconBaseName_Empty_ReturnsUnknown() + { + Assert.Equal("unknown", AssistIconLoader.GetSeverityIconBaseName("")); + } + + [Fact] + public void GetSeverityIconBaseName_UnknownSeverity_ReturnsUnknown() + { + Assert.Equal("unknown", AssistIconLoader.GetSeverityIconBaseName("CustomSeverity")); + } + + [Fact] + public void GetSeverityIconFileName_AllSeverityLevels_ReturnNonEmptyPng() + { + foreach (SeverityLevel sev in System.Enum.GetValues(typeof(SeverityLevel))) + { + var name = AssistIconLoader.GetSeverityIconFileName(sev); + Assert.False(string.IsNullOrEmpty(name)); + Assert.EndsWith(".png", name); + } + } + + [Fact] + public void GetSeverityIconBaseName_WhitespaceOnly_ReturnsUnknown() + { + Assert.Equal("unknown", AssistIconLoader.GetSeverityIconBaseName(" ")); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistConstantsTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistConstantsTests.cs new file mode 100644 index 00000000..6356b4e3 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistConstantsTests.cs @@ -0,0 +1,472 @@ +using System; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for CxAssistConstants (line conversions, severity checks, string formatting, labels). + /// + public class CxAssistConstantsTests + { + #region To0BasedLineForEditor + + [Theory] + [InlineData(1, 0)] + [InlineData(5, 4)] + [InlineData(100, 99)] + public void To0BasedLineForEditor_PositiveLineNumber_ReturnsZeroBased(int lineNumber, int expected) + { + var result = CxAssistConstants.To0BasedLineForEditor(ScannerType.OSS, lineNumber); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void To0BasedLineForEditor_ZeroOrNegative_ReturnsZero(int lineNumber) + { + var result = CxAssistConstants.To0BasedLineForEditor(ScannerType.ASCA, lineNumber); + Assert.Equal(0, result); + } + + [Theory] + [InlineData(ScannerType.OSS)] + [InlineData(ScannerType.Secrets)] + [InlineData(ScannerType.Containers)] + [InlineData(ScannerType.IaC)] + [InlineData(ScannerType.ASCA)] + public void To0BasedLineForEditor_AllScannerTypes_BehaveSame(ScannerType scanner) + { + Assert.Equal(9, CxAssistConstants.To0BasedLineForEditor(scanner, 10)); + } + + #endregion + + #region To1BasedLineForDte + + [Theory] + [InlineData(1, 1)] + [InlineData(5, 5)] + [InlineData(100, 100)] + public void To1BasedLineForDte_PositiveLineNumber_ReturnsSameValue(int lineNumber, int expected) + { + var result = CxAssistConstants.To1BasedLineForDte(ScannerType.OSS, lineNumber); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(-100)] + public void To1BasedLineForDte_ZeroOrNegative_ReturnsOne(int lineNumber) + { + var result = CxAssistConstants.To1BasedLineForDte(ScannerType.IaC, lineNumber); + Assert.Equal(1, result); + } + + #endregion + + #region IsProblem + + [Theory] + [InlineData(SeverityLevel.Critical, true)] + [InlineData(SeverityLevel.High, true)] + [InlineData(SeverityLevel.Medium, true)] + [InlineData(SeverityLevel.Low, true)] + [InlineData(SeverityLevel.Info, true)] + [InlineData(SeverityLevel.Malicious, true)] + [InlineData(SeverityLevel.Ok, false)] + [InlineData(SeverityLevel.Unknown, false)] + [InlineData(SeverityLevel.Ignored, false)] + public void IsProblem_AllSeverityLevels_ReturnsCorrectResult(SeverityLevel severity, bool expected) + { + Assert.Equal(expected, CxAssistConstants.IsProblem(severity)); + } + + #endregion + + #region IsLineInRange + + [Fact] + public void IsLineInRange_LineOne_InRange() + { + Assert.True(CxAssistConstants.IsLineInRange(1, 10)); + } + + [Fact] + public void IsLineInRange_LastLine_InRange() + { + Assert.True(CxAssistConstants.IsLineInRange(10, 10)); + } + + [Fact] + public void IsLineInRange_ZeroLine_OutOfRange() + { + Assert.False(CxAssistConstants.IsLineInRange(0, 10)); + } + + [Fact] + public void IsLineInRange_NegativeLine_OutOfRange() + { + Assert.False(CxAssistConstants.IsLineInRange(-1, 10)); + } + + [Fact] + public void IsLineInRange_BeyondLastLine_OutOfRange() + { + Assert.False(CxAssistConstants.IsLineInRange(11, 10)); + } + + [Fact] + public void IsLineInRange_ZeroLineCount_OutOfRange() + { + Assert.False(CxAssistConstants.IsLineInRange(1, 0)); + } + + #endregion + + #region StripCveFromDisplayName + + [Theory] + [InlineData("node-ipc (CVE-2022-12345)", "node-ipc")] + [InlineData("pkg (CVE-2024-1234) extra", "pkg extra")] + [InlineData("node-ipc (Malicious)", "node-ipc")] + [InlineData("node-ipc (malicious)", "node-ipc")] + [InlineData("node-ipc (MALICIOUS)", "node-ipc")] + [InlineData("clean-package", "clean-package")] + [InlineData("pkg (CVE-2022-111) (Malicious)", "pkg")] + public void StripCveFromDisplayName_VariousInputs_ReturnsExpected(string input, string expected) + { + Assert.Equal(expected, CxAssistConstants.StripCveFromDisplayName(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void StripCveFromDisplayName_NullOrEmpty_ReturnsAsIs(string input) + { + Assert.Equal(input, CxAssistConstants.StripCveFromDisplayName(input)); + } + + [Fact] + public void StripCveFromDisplayName_WhitespaceInput_ReturnsEmpty() + { + Assert.Equal("", CxAssistConstants.StripCveFromDisplayName(" ")); + } + + #endregion + + #region FormatSecretTitle + + [Theory] + [InlineData("generic-api-key", "Generic-Api-Key")] + [InlineData("aws-secret-key", "Aws-Secret-Key")] + [InlineData("simple", "Simple")] + [InlineData("a-b-c", "A-B-C")] + public void FormatSecretTitle_KebabCase_ReturnsTitleCase(string input, string expected) + { + Assert.Equal(expected, CxAssistConstants.FormatSecretTitle(input)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void FormatSecretTitle_NullOrEmpty_ReturnsAsIs(string input) + { + Assert.Equal(input, CxAssistConstants.FormatSecretTitle(input)); + } + + [Fact] + public void FormatSecretTitle_SingleChar_ReturnsUppercase() + { + Assert.Equal("A", CxAssistConstants.FormatSecretTitle("a")); + } + + [Fact] + public void FormatSecretTitle_AlreadyTitleCase_ReturnsSame() + { + Assert.Equal("Generic-Api-Key", CxAssistConstants.FormatSecretTitle("Generic-Api-Key")); + } + + [Fact] + public void FormatSecretTitle_AllUpperCase_ReturnsNormalized() + { + Assert.Equal("Generic-Api-Key", CxAssistConstants.FormatSecretTitle("GENERIC-API-KEY")); + } + + #endregion + + #region GetRichSeverityName + + [Theory] + [InlineData(SeverityLevel.Critical, "Critical")] + [InlineData(SeverityLevel.High, "High")] + [InlineData(SeverityLevel.Medium, "Medium")] + [InlineData(SeverityLevel.Low, "Low")] + [InlineData(SeverityLevel.Info, "Info")] + [InlineData(SeverityLevel.Malicious, "Malicious")] + [InlineData(SeverityLevel.Unknown, "Unknown")] + [InlineData(SeverityLevel.Ok, "Ok")] + [InlineData(SeverityLevel.Ignored, "Ignored")] + public void GetRichSeverityName_AllLevels_ReturnsExpected(SeverityLevel severity, string expected) + { + Assert.Equal(expected, CxAssistConstants.GetRichSeverityName(severity)); + } + + #endregion + + #region GetIgnoreThisLabel + + [Fact] + public void GetIgnoreThisLabel_Secrets_ReturnsSecretLabel() + { + Assert.Equal("Ignore this secret in file", CxAssistConstants.GetIgnoreThisLabel(ScannerType.Secrets)); + } + + [Theory] + [InlineData(ScannerType.OSS)] + [InlineData(ScannerType.Containers)] + [InlineData(ScannerType.IaC)] + [InlineData(ScannerType.ASCA)] + public void GetIgnoreThisLabel_NonSecrets_ReturnsVulnerabilityLabel(ScannerType scanner) + { + Assert.Equal("Ignore this vulnerability", CxAssistConstants.GetIgnoreThisLabel(scanner)); + } + + #endregion + + #region ShouldShowIgnoreAll + + [Theory] + [InlineData(ScannerType.OSS, true)] + [InlineData(ScannerType.Containers, true)] + [InlineData(ScannerType.Secrets, false)] + [InlineData(ScannerType.IaC, false)] + [InlineData(ScannerType.ASCA, false)] + public void ShouldShowIgnoreAll_ReturnsCorrectResult(ScannerType scanner, bool expected) + { + Assert.Equal(expected, CxAssistConstants.ShouldShowIgnoreAll(scanner)); + } + + #endregion + + #region GetIgnoreThisSuccessMessage + + [Theory] + [InlineData(ScannerType.Secrets, "Secret ignored.")] + [InlineData(ScannerType.Containers, "Container image ignored.")] + [InlineData(ScannerType.IaC, "IaC finding ignored.")] + [InlineData(ScannerType.ASCA, "ASCA violation ignored.")] + [InlineData(ScannerType.OSS, "Vulnerability ignored.")] + public void GetIgnoreThisSuccessMessage_AllScanners_ReturnsExpected(ScannerType scanner, string expected) + { + Assert.Equal(expected, CxAssistConstants.GetIgnoreThisSuccessMessage(scanner)); + } + + #endregion + + #region GetIgnoreAllSuccessMessage + + [Theory] + [InlineData(ScannerType.Secrets, "All secrets ignored.")] + [InlineData(ScannerType.Containers, "All container issues ignored.")] + [InlineData(ScannerType.IaC, "All IaC findings ignored.")] + [InlineData(ScannerType.ASCA, "All ASCA violations ignored.")] + [InlineData(ScannerType.OSS, "All OSS issues ignored.")] + public void GetIgnoreAllSuccessMessage_AllScanners_ReturnsExpected(ScannerType scanner, string expected) + { + Assert.Equal(expected, CxAssistConstants.GetIgnoreAllSuccessMessage(scanner)); + } + + #endregion + + #region Constants + + [Fact] + public void DisplayName_IsCheckmarxOneAssist() + { + Assert.Equal("Checkmarx One Assist", CxAssistConstants.DisplayName); + } + + [Fact] + public void GetIgnoreAllLabel_ReturnsCorrectLabel() + { + Assert.Equal("Ignore all of this type", CxAssistConstants.GetIgnoreAllLabel(ScannerType.OSS)); + } + + [Fact] + public void MultipleIacIssuesOnLine_Constant_IsExpectedSuffix() + { + Assert.Equal(" IAC issues detected on this line", CxAssistConstants.MultipleIacIssuesOnLine); + } + + [Fact] + public void MultipleAscaViolationsOnLine_Constant_IsExpectedSuffix() + { + Assert.Equal(" ASCA violations detected on this line", CxAssistConstants.MultipleAscaViolationsOnLine); + } + + [Fact] + public void MultipleOssIssuesOnLine_Constant_IsExpectedSuffix() + { + Assert.Equal(" OSS issues detected on this line", CxAssistConstants.MultipleOssIssuesOnLine); + } + + [Fact] + public void MultipleSecretsIssuesOnLine_Constant_IsExpectedSuffix() + { + Assert.Equal(" Secrets issues detected on this line", CxAssistConstants.MultipleSecretsIssuesOnLine); + } + + [Fact] + public void MultipleContainersIssuesOnLine_Constant_IsExpectedSuffix() + { + Assert.Equal(" Container issues detected on this line", CxAssistConstants.MultipleContainersIssuesOnLine); + } + + [Fact] + public void LogCategory_IsCxAssist() + { + Assert.Equal("CxAssist", CxAssistConstants.LogCategory); + } + + [Fact] + public void ThemeDark_IsDark() + { + Assert.Equal("Dark", CxAssistConstants.ThemeDark); + } + + [Fact] + public void ThemeLight_IsLight() + { + Assert.Equal("Light", CxAssistConstants.ThemeLight); + } + + [Fact] + public void BadgeIconFileName_IsExpected() + { + Assert.Equal("cxone_assist.png", CxAssistConstants.BadgeIconFileName); + } + + [Fact] + public void FixWithCxOneAssist_Label_IsExpected() + { + Assert.Equal("Fix with Checkmarx One Assist", CxAssistConstants.FixWithCxOneAssist); + } + + [Fact] + public void ViewDetails_Label_IsExpected() + { + Assert.Equal("View details", CxAssistConstants.ViewDetails); + } + + [Fact] + public void CopyMessage_Label_IsExpected() + { + Assert.Equal("Copy Message", CxAssistConstants.CopyMessage); + } + + [Fact] + public void SecretFindingLabel_IsExpected() + { + Assert.Equal("Secret finding", CxAssistConstants.SecretFindingLabel); + } + + [Fact] + public void SeverityPackageLabel_IsExpected() + { + Assert.Equal("Severity Package", CxAssistConstants.SeverityPackageLabel); + } + + [Fact] + public void SeverityImageLabel_IsExpected() + { + Assert.Equal("Severity Image", CxAssistConstants.SeverityImageLabel); + } + + [Fact] + public void GetRichSeverityName_UnmappedEnum_ReturnsToString() + { + var unmapped = (SeverityLevel)99; + Assert.Equal("99", CxAssistConstants.GetRichSeverityName(unmapped)); + } + + [Fact] + public void IsLineInRange_LineOne_LineCountOne_InRange() + { + Assert.True(CxAssistConstants.IsLineInRange(1, 1)); + } + + [Fact] + public void IsLineInRange_LineZero_LineCountOne_OutOfRange() + { + Assert.False(CxAssistConstants.IsLineInRange(0, 1)); + } + + [Fact] + public void IsLineInRange_LineTwo_LineCountOne_OutOfRange() + { + Assert.False(CxAssistConstants.IsLineInRange(2, 1)); + } + + [Fact] + public void FormatSecretTitle_SingleHyphen_ReturnsTwoParts() + { + Assert.Equal("A-B", CxAssistConstants.FormatSecretTitle("a-b")); + } + + [Fact] + public void FormatSecretTitle_NoHyphen_ReturnsCapitalized() + { + Assert.Equal("Single", CxAssistConstants.FormatSecretTitle("single")); + } + + [Fact] + public void StripCveFromDisplayName_MultipleCvePatterns_StripsAll() + { + var input = "pkg (CVE-2020-001) (CVE-2021-002)"; + Assert.Equal("pkg", CxAssistConstants.StripCveFromDisplayName(input)); + } + + [Fact] + public void IacVulnerabilityLabel_IsExpected() + { + Assert.Equal("IaC vulnerability", CxAssistConstants.IacVulnerabilityLabel); + } + + [Fact] + public void SastVulnerabilityLabel_IsExpected() + { + Assert.Equal("SAST vulnerability", CxAssistConstants.SastVulnerabilityLabel); + } + + [Fact] + public void SyncFindingsToBuiltInErrorList_IsBooleanConstant() + { + Assert.True(CxAssistConstants.SyncFindingsToBuiltInErrorList || !CxAssistConstants.SyncFindingsToBuiltInErrorList); + } + + [Fact] + public void CopilotFixFallbackMessage_ContainsCopiedOrPaste() + { + Assert.Contains("copied", CxAssistConstants.CopilotFixFallbackMessage.ToLower()); + } + + [Fact] + public void IgnoreThis_Constant_IsExpected() + { + Assert.Equal("Ignore this vulnerability", CxAssistConstants.IgnoreThis); + } + + [Fact] + public void IgnoreAllOfThisType_Constant_IsExpected() + { + Assert.Equal("Ignore all of this type", CxAssistConstants.IgnoreAllOfThisType); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistDisplayCoordinatorTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistDisplayCoordinatorTests.cs new file mode 100644 index 00000000..eedef02f --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistDisplayCoordinatorTests.cs @@ -0,0 +1,458 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for CxAssistDisplayCoordinator (per-file issue storage, lookup, events). + /// Only tests pure logic (storage, lookup, events); buffer-dependent methods are tested via integration tests. + /// + public class CxAssistDisplayCoordinatorTests + { + /// + /// Helper to clear coordinator state before each test to avoid cross-test contamination. + /// + private void ClearCoordinator() + { + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary>()); + } + + #region SetFindingsByFile + + [Fact] + public void SetFindingsByFile_NullInput_DoesNotThrow() + { + CxAssistDisplayCoordinator.SetFindingsByFile(null); + } + + [Fact] + public void SetFindingsByFile_EmptyDictionary_ClearsFindings() + { + ClearCoordinator(); + + var result = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void SetFindingsByFile_WithData_StoresFindings() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { + @"C:\src\package.json", new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\package.json") + } + } + }; + + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + + var result = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.NotEmpty(result); + } + + [Fact] + public void SetFindingsByFile_SkipsNullKeyOrValue() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { "", new List { new Vulnerability("V1", "Issue", "Desc", SeverityLevel.High, ScannerType.OSS, 1, 1, null) } }, + }; + + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + var result = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + // Empty key should be skipped + Assert.Empty(result); + } + + #endregion + + #region GetCurrentFindings + + [Fact] + public void GetCurrentFindings_NoData_ReturnsNull() + { + ClearCoordinator(); + var result = CxAssistDisplayCoordinator.GetCurrentFindings(); + Assert.Null(result); + } + + [Fact] + public void GetCurrentFindings_WithData_ReturnsFlatList() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { + @"C:\src\file1.cs", new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 1, 1, @"C:\src\file1.cs"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 2, 1, @"C:\src\file1.cs") + } + }, + { + @"C:\src\file2.cs", new List + { + new Vulnerability("V3", "Issue3", "Desc3", SeverityLevel.Low, ScannerType.Secrets, 5, 1, @"C:\src\file2.cs") + } + } + }; + + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + var result = CxAssistDisplayCoordinator.GetCurrentFindings(); + + Assert.NotNull(result); + Assert.Equal(3, result.Count); + } + + #endregion + + #region FindVulnerabilityById + + [Fact] + public void FindVulnerabilityById_Null_ReturnsNull() + { + ClearCoordinator(); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById(null)); + } + + [Fact] + public void FindVulnerabilityById_Empty_ReturnsNull() + { + ClearCoordinator(); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById("")); + } + + [Fact] + public void FindVulnerabilityById_ExistingId_ReturnsVulnerability() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { + @"C:\src\file.cs", new List + { + new Vulnerability("V-001", "SQL Injection", "Desc", SeverityLevel.High, ScannerType.ASCA, 10, 1, @"C:\src\file.cs"), + new Vulnerability("V-002", "XSS", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 20, 1, @"C:\src\file.cs") + } + } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + + var found = CxAssistDisplayCoordinator.FindVulnerabilityById("V-002"); + Assert.NotNull(found); + Assert.Equal("XSS", found.Title); + } + + [Fact] + public void FindVulnerabilityById_NonExistingId_ReturnsNull() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { + @"C:\src\file.cs", new List + { + new Vulnerability("V-001", "Issue", "Desc", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs") + } + } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById("V-999")); + } + + [Fact] + public void FindVulnerabilityById_CaseInsensitive() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { + @"C:\src\file.cs", new List + { + new Vulnerability("V-001", "Issue", "Desc", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs") + } + } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + + var found = CxAssistDisplayCoordinator.FindVulnerabilityById("v-001"); + Assert.NotNull(found); + } + + #endregion + + #region IssuesUpdated Event + + [Fact] + public void SetFindingsByFile_RaisesIssuesUpdated() + { + ClearCoordinator(); + bool eventFired = false; + void handler(System.Collections.Generic.IReadOnlyDictionary> _) { eventFired = true; } + + CxAssistDisplayCoordinator.IssuesUpdated += handler; + try + { + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary> + { + { @"C:\src\file.cs", new List + { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs") } } + }); + + Assert.True(eventFired); + } + finally + { + CxAssistDisplayCoordinator.IssuesUpdated -= handler; + } + } + + [Fact] + public void SetFindingsByFile_EventContainsSnapshot() + { + ClearCoordinator(); + IReadOnlyDictionary> snapshot = null; + void handler(IReadOnlyDictionary> data) { snapshot = data; } + + CxAssistDisplayCoordinator.IssuesUpdated += handler; + try + { + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary> + { + { @"C:\src\file.cs", new List + { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs") } } + }); + + Assert.NotNull(snapshot); + Assert.NotEmpty(snapshot); + } + finally + { + CxAssistDisplayCoordinator.IssuesUpdated -= handler; + } + } + + #endregion + + #region GetAllIssuesByFile - Snapshot Isolation + + [Fact] + public void GetAllIssuesByFile_ReturnsCopy_NotReference() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { + @"C:\src\file.cs", new List + { + new Vulnerability("V1", "Issue", "Desc", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs") + } + } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + + var snapshot1 = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + var snapshot2 = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + + // Different references + Assert.NotSame(snapshot1, snapshot2); + } + + [Fact] + public void SetFindingsByFile_EmptyDictionary_GetCurrentFindingsReturnsNull() + { + ClearCoordinator(); + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary>()); + + var result = CxAssistDisplayCoordinator.GetCurrentFindings(); + Assert.Null(result); + } + + [Fact] + public void SetFindingsByFile_ReplacesPreviousFindings() + { + ClearCoordinator(); + var first = new Dictionary> + { + { @"C:\src\a.cs", new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\a.cs") } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(first); + var second = new Dictionary> + { + { @"C:\src\b.cs", new List { new Vulnerability("V2", "T2", "D2", SeverityLevel.Medium, ScannerType.ASCA, 5, 1, @"C:\src\b.cs") } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(second); + + var all = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.Single(all); + Assert.Contains(@"C:\src\b.cs", all.Keys); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById("V1")); + Assert.NotNull(CxAssistDisplayCoordinator.FindVulnerabilityById("V2")); + } + + [Fact] + public void FindVulnerabilityById_MultipleFiles_SearchesAll() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { @"C:\src\file1.cs", new List { new Vulnerability("ID-1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file1.cs") } }, + { @"C:\src\file2.cs", new List { new Vulnerability("ID-2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 1, 1, @"C:\src\file2.cs") } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + + var found1 = CxAssistDisplayCoordinator.FindVulnerabilityById("ID-1"); + var found2 = CxAssistDisplayCoordinator.FindVulnerabilityById("ID-2"); + + Assert.NotNull(found1); + Assert.Equal("Issue1", found1.Title); + Assert.NotNull(found2); + Assert.Equal("Issue2", found2.Title); + } + + [Fact] + public void SetFindingsByFile_NoEventSubscriber_DoesNotThrow() + { + ClearCoordinator(); + var ex = Record.Exception(() => + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary> + { + { @"C:\src\file.cs", new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs") } } + })); + + Assert.Null(ex); + } + + #endregion + + #region FindVulnerabilityByLocation + + [Fact] + public void FindVulnerabilityByLocation_NullDocumentPath_ReturnsNull() + { + ClearCoordinator(); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation(null, 0)); + } + + [Fact] + public void FindVulnerabilityByLocation_EmptyDocumentPath_ReturnsNull() + { + ClearCoordinator(); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation("", 0)); + } + + [Fact] + public void FindVulnerabilityByLocation_NoData_ReturnsNull() + { + ClearCoordinator(); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation(@"C:\src\file.cs", 0)); + } + + [Fact] + public void FindVulnerabilityByLocation_ZeroBasedLine_MatchesFirstVulnerabilityOnLine() + { + ClearCoordinator(); + var path = @"C:\src\app.cs"; + var issues = new Dictionary> + { + { + path, new List + { + new Vulnerability("V1", "First", "Desc1", SeverityLevel.High, ScannerType.ASCA, 11, 1, path), + new Vulnerability("V2", "Second", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 11, 1, path) + } + } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + // Line 11 is 1-based -> 0-based line 10 + var found = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 10); + Assert.NotNull(found); + Assert.True(found.LineNumber == 11); + } + + [Fact] + public void FindVulnerabilityByLocation_ZeroBasedLineZero_MatchesLineOne() + { + ClearCoordinator(); + var path = @"C:\project\file.cs"; + var issues = new Dictionary> + { + { path, new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, path) } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + var found = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 0); + Assert.NotNull(found); + Assert.Equal(1, found.LineNumber); + } + + [Fact] + public void FindVulnerabilityByLocation_NonMatchingLine_ReturnsNull() + { + ClearCoordinator(); + var path = @"C:\src\file.cs"; + var issues = new Dictionary> + { + { path, new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 5, 1, path) } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 0)); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 99)); + } + + [Fact] + public void FindVulnerabilityByLocation_FileNotInFindings_ReturnsNull() + { + ClearCoordinator(); + var issues = new Dictionary> + { + { @"C:\src\other.cs", new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\other.cs") } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(issues); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation(@"C:\src\file.cs", 0)); + } + + [Fact] + public void GetAllIssuesByFile_AfterSetFindingsByFile_KeysMatchInput() + { + ClearCoordinator(); + var path1 = @"C:\src\a.cs"; + var path2 = @"C:\src\b.cs"; + var byFile = new Dictionary> + { + { path1, new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, path1) } }, + { path2, new List { new Vulnerability("V2", "T", "D", SeverityLevel.Medium, ScannerType.ASCA, 1, 1, path2) } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + var all = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.Equal(2, all.Count); + Assert.True(all.ContainsKey(path1) || all.Keys.Any(k => string.Equals(k, path1, System.StringComparison.OrdinalIgnoreCase))); + Assert.True(all.ContainsKey(path2) || all.Keys.Any(k => string.Equals(k, path2, System.StringComparison.OrdinalIgnoreCase))); + } + + [Fact] + public void SetFindingsByFile_NullValueInDictionary_KeySkipped() + { + ClearCoordinator(); + var byFile = new Dictionary> + { + { @"C:\src\valid.cs", new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\valid.cs") } }, + { @"C:\src\nullvalue", null } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + var all = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.Single(all); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistErrorHandlerTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistErrorHandlerTests.cs new file mode 100644 index 00000000..30b3ad91 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistErrorHandlerTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for CxAssist error-handling scenarios. + /// Verifies that TryRun, TryGet, and LogAndSwallow never rethrow and behave correctly. + /// + public class CxAssistErrorHandlerTests + { + [Fact] + public void TryRun_ReturnsTrue_WhenActionSucceeds() + { + bool executed = false; + bool result = CxAssistErrorHandler.TryRun(() => { executed = true; }, "Test"); + + Assert.True(result); + Assert.True(executed); + } + + [Fact] + public void TryRun_ReturnsFalse_WhenActionThrows() + { + bool result = CxAssistErrorHandler.TryRun(() => throw new InvalidOperationException("Test exception"), "Test"); + + Assert.False(result); + } + + [Fact] + public void TryRun_DoesNotRethrow_WhenActionThrows() + { + var ex = Record.Exception(() => + CxAssistErrorHandler.TryRun(() => throw new InvalidOperationException("Test"), "Test")); + + Assert.Null(ex); + } + + [Fact] + public void TryRun_HandlesNullAction_WithoutThrowing() + { + bool result = CxAssistErrorHandler.TryRun(null, "Test"); + + Assert.True(result); + } + + [Fact] + public void TryGet_ReturnsValue_WhenFunctionSucceeds() + { + int value = CxAssistErrorHandler.TryGet(() => 42, "Test", 0); + + Assert.Equal(42, value); + } + + [Fact] + public void TryGet_ReturnsDefault_WhenFunctionThrows() + { + int value = CxAssistErrorHandler.TryGet(() => throw new InvalidOperationException("Test"), "Test", 99); + + Assert.Equal(99, value); + } + + [Fact] + public void TryGet_DoesNotRethrow_WhenFunctionThrows() + { + var ex = Record.Exception(() => + CxAssistErrorHandler.TryGet(() => throw new InvalidOperationException("Test"), "Test", 0)); + + Assert.Null(ex); + } + + [Fact] + public void TryGet_ReturnsDefaultT_WhenFunctionIsNull() + { + int value = CxAssistErrorHandler.TryGet(null, "Test", 7); + + Assert.Equal(7, value); + } + + [Fact] + public void LogAndSwallow_DoesNotThrow_WhenGivenException() + { + var ex = Record.Exception(() => + CxAssistErrorHandler.LogAndSwallow(new InvalidOperationException("Test"), "TestContext")); + + Assert.Null(ex); + } + + [Fact] + public void LogAndSwallow_DoesNotThrow_WhenGivenNull() + { + var ex = Record.Exception(() => + CxAssistErrorHandler.LogAndSwallow(null, "TestContext")); + + Assert.Null(ex); + } + + [Fact] + public void TryRun_WithNullContextMessage_DoesNotThrow() + { + var ex = Record.Exception(() => + CxAssistErrorHandler.TryRun(() => { }, null)); + Assert.Null(ex); + } + + [Fact] + public void TryGet_WithNullContextMessage_ReturnsValueWhenFunctionSucceeds() + { + int value = CxAssistErrorHandler.TryGet(() => 100, null, -1); + Assert.Equal(100, value); + } + + [Fact] + public void TryGet_WithNullContextMessage_ReturnsDefaultWhenFunctionThrows() + { + string value = CxAssistErrorHandler.TryGet(() => throw new Exception("Test"), null, "default"); + Assert.Equal("default", value); + } + + [Fact] + public void TryGet_ReturnsDefaultBool_WhenFunctionThrows() + { + bool value = CxAssistErrorHandler.TryGet(() => throw new Exception(), "Ctx", false); + Assert.False(value); + } + + [Fact] + public void TryGet_ReturnsNullReferenceType_WhenDefaultIsNull() + { + string value = CxAssistErrorHandler.TryGet(() => throw new Exception(), "Ctx", null); + Assert.Null(value); + } + + [Fact] + public void TryGet_ReturnsEmptyList_WhenFunctionThrowsAndDefaultEmptyList() + { + var defaultValue = new List(); + var result = CxAssistErrorHandler.TryGet>(() => throw new Exception(), "Ctx", defaultValue); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void TryRun_ActionThrowsAggregateException_ReturnsFalse() + { + bool result = CxAssistErrorHandler.TryRun(() => throw new System.AggregateException("Agg"), "Ctx"); + Assert.False(result); + } + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistIntegrationTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistIntegrationTests.cs new file mode 100644 index 00000000..afffb38b --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistIntegrationTests.cs @@ -0,0 +1,900 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Prompts; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Integration tests for CxAssist: multi-component flows (MockData → TreeBuilder → Coordinator, Prompts). + /// No VS or WPF required; tests use in-memory APIs only. + /// + public class CxAssistIntegrationTests + { + private static void ClearCoordinator() + { + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary>()); + } + + #region MockData → TreeBuilder + + [Fact] + public void Integration_CommonMockData_ToTreeBuilder_ProducesValidFileNodes() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(@"C:\src\Program.cs"); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + Assert.NotNull(fileNodes); + Assert.NotEmpty(fileNodes); + Assert.Single(fileNodes); + var fileNode = fileNodes[0]; + Assert.Equal("Program.cs", fileNode.FileName); + Assert.Equal(@"C:\src\Program.cs", fileNode.FilePath); + Assert.NotNull(fileNode.Vulnerabilities); + Assert.NotEmpty(fileNode.Vulnerabilities); + Assert.NotNull(fileNode.SeverityCounts); + Assert.All(fileNode.Vulnerabilities, v => Assert.NotNull(v.Severity)); + Assert.All(fileNode.Vulnerabilities, v => Assert.NotNull(v.Description)); + } + + [Fact] + public void Integration_CommonMockData_ToTreeBuilder_OnlyProblemSeveritiesInTree() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + var allSeverities = fileNodes.SelectMany(f => f.Vulnerabilities.Select(v => v.Severity)).ToList(); + Assert.DoesNotContain("Ok", allSeverities); + Assert.DoesNotContain("Unknown", allSeverities); + Assert.DoesNotContain("Ignored", allSeverities); + } + + [Fact] + public void Integration_PackageJsonMockData_ToTreeBuilder_OkAndUnknownFilteredOut() + { + var vulnerabilities = CxAssistMockData.GetPackageJsonMockVulnerabilities(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + Assert.NotNull(fileNodes); + var problemCount = vulnerabilities.Count(v => CxAssistConstants.IsProblem(v.Severity)); + var treeVulnCount = fileNodes.Sum(f => f.Vulnerabilities.Count); + Assert.True(treeVulnCount <= problemCount, "Tree groups same-line findings so count can be less."); + Assert.True(problemCount == 0 || treeVulnCount > 0, "All problem findings should appear in tree (possibly grouped)."); + } + + [Fact] + public void Integration_PomMockData_ToTreeBuilder_ProducesFileNodeWithOssFindings() + { + var path = @"C:\project\pom.xml"; + var vulnerabilities = CxAssistMockData.GetPomMockVulnerabilities(path); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + Assert.NotNull(fileNodes); + if (fileNodes.Count > 0) + { + Assert.Equal("pom.xml", fileNodes[0].FileName); + Assert.Equal(path, fileNodes[0].FilePath); + } + } + + [Fact] + public void Integration_SecretsMockData_ToTreeBuilder_ContainsSecretsAndAsca() + { + var vulnerabilities = CxAssistMockData.GetSecretsPyMockVulnerabilities(@"C:\src\secrets.py"); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + Assert.NotNull(fileNodes); + Assert.NotEmpty(fileNodes); + var allScanners = fileNodes.SelectMany(f => f.Vulnerabilities).Select(v => v.Scanner).Distinct().ToList(); + Assert.Contains(ScannerType.Secrets, allScanners); + Assert.Contains(ScannerType.ASCA, allScanners); + } + + [Fact] + public void Integration_IacMockData_ToTreeBuilder_AllIacScanner() + { + var vulnerabilities = CxAssistMockData.GetIacMockVulnerabilities(@"C:\src\main.tf"); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + Assert.NotNull(fileNodes); + Assert.NotEmpty(fileNodes); + Assert.All(fileNodes.SelectMany(f => f.Vulnerabilities), v => Assert.Equal(ScannerType.IaC, v.Scanner)); + } + + [Fact] + public void Integration_ContainerMockData_ToTreeBuilder_AllContainersScanner() + { + var vulnerabilities = CxAssistMockData.GetContainerMockVulnerabilities(@"C:\src\Dockerfile"); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + Assert.NotNull(fileNodes); + Assert.NotEmpty(fileNodes); + Assert.All(fileNodes.SelectMany(f => f.Vulnerabilities), v => Assert.Equal(ScannerType.Containers, v.Scanner)); + } + + #endregion + + #region Coordinator + TreeBuilder (SetFindingsByFile → GetCurrentFindings → BuildFileNodes) + + [Fact] + public void Integration_CoordinatorSetFindings_GetCurrentFindings_MatchesInput() + { + ClearCoordinator(); + var path = @"C:\src\app.cs"; + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(path); + var byFile = new Dictionary> { { path, vulnerabilities } }; + + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + + Assert.NotNull(current); + Assert.Equal(vulnerabilities.Count, current.Count); + } + + [Fact] + public void Integration_CoordinatorSetFindings_BuildFileNodesFromCurrent_ProducesConsistentTree() + { + ClearCoordinator(); + var path = @"C:\src\Program.cs"; + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(path); + var byFile = new Dictionary> { { path, vulnerabilities } }; + + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(current); + + Assert.NotNull(fileNodes); + Assert.NotEmpty(fileNodes); + var problemCount = vulnerabilities.Count(v => CxAssistConstants.IsProblem(v.Severity)); + var treeCount = fileNodes[0].Vulnerabilities.Count; + Assert.True(treeCount <= problemCount, "Same-line grouping can reduce tree node count."); + Assert.True(treeCount > 0, "Tree should have at least one vulnerability node."); + } + + [Fact] + public void Integration_CoordinatorFindVulnerabilityById_FromMockData_ReturnsCorrectVulnerability() + { + ClearCoordinator(); + var path = @"C:\src\Program.cs"; + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(path); + var byFile = new Dictionary> { { path, vulnerabilities } }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + + var first = vulnerabilities.First(v => CxAssistConstants.IsProblem(v.Severity)); + var found = CxAssistDisplayCoordinator.FindVulnerabilityById(first.Id); + + Assert.NotNull(found); + Assert.Equal(first.Id, found.Id); + Assert.Equal(first.Title, found.Title); + Assert.Equal(first.Severity, found.Severity); + } + + [Fact] + public void Integration_CoordinatorFindVulnerabilityByLocation_FromMockData_ReturnsVulnerabilityOnLine() + { + ClearCoordinator(); + var path = @"C:\src\Program.cs"; + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(path); + var byFile = new Dictionary> { { path, vulnerabilities } }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + + int line1Based = 1; + int zeroBased = CxAssistConstants.To0BasedLineForEditor(ScannerType.OSS, line1Based); + var found = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, zeroBased); + + Assert.NotNull(found); + Assert.Equal(line1Based, found.LineNumber); + } + + [Fact] + public void Integration_MultiFileMockData_CoordinatorGetAllIssuesByFile_ThenTreeBuilder_ProducesMultipleFileNodes() + { + ClearCoordinator(); + var packageJson = CxAssistMockData.GetPackageJsonMockVulnerabilities(@"C:\project\package.json"); + var pom = CxAssistMockData.GetPomMockVulnerabilities(@"C:\project\pom.xml"); + var byFile = new Dictionary> + { + { @"C:\project\package.json", packageJson }, + { @"C:\project\pom.xml", pom } + }; + + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + var all = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.Equal(2, all.Count); + + // Build tree from GetAllIssuesByFile snapshot (avoids relying on GetCurrentFindings() when tests run in parallel) + var flattened = all.Values.SelectMany(list => list ?? new List()).ToList(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(flattened); + + Assert.True(fileNodes.Count >= 1, "Tree should have at least one file node from package.json and/or pom.xml (problem-level findings)."); + var fileNames = fileNodes.Select(f => f.FileName).ToList(); + Assert.Contains("package.json", fileNames); + Assert.Contains("pom.xml", fileNames); + } + + #endregion + + #region MockData / Vulnerability → Prompts (Fix + ViewDetails) + + [Fact] + public void Integration_CommonMockVulnerability_FixPrompt_And_ViewDetailsPrompt_BothNonNull() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var firstProblem = vulnerabilities.First(v => CxAssistConstants.IsProblem(v.Severity)); + + var fixPrompt = CxOneAssistFixPrompts.BuildForVulnerability(firstProblem); + var viewPrompt = ViewDetailsPrompts.BuildForVulnerability(firstProblem); + + Assert.NotNull(fixPrompt); + Assert.NotNull(viewPrompt); + Assert.Contains("Checkmarx One Assist", fixPrompt); + Assert.Contains("Checkmarx One Assist", viewPrompt); + } + + [Fact] + public void Integration_OssVulnerability_FixPrompt_ContainsPackageAndRemediation() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var oss = vulnerabilities.First(v => v.Scanner == ScannerType.OSS && CxAssistConstants.IsProblem(v.Severity)); + + var fixPrompt = CxOneAssistFixPrompts.BuildForVulnerability(oss); + + Assert.NotNull(fixPrompt); + Assert.Contains(oss.PackageName ?? oss.Title, fixPrompt); + Assert.True(fixPrompt.Contains("PackageRemediation") || fixPrompt.Contains("remediat")); + } + + [Fact] + public void Integration_AscaVulnerability_ViewDetailsPrompt_ContainsRuleAndDescription() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var asca = vulnerabilities.First(v => v.Scanner == ScannerType.ASCA); + + var viewPrompt = ViewDetailsPrompts.BuildForVulnerability(asca); + + Assert.NotNull(viewPrompt); + Assert.Contains(asca.RuleName ?? asca.Title, viewPrompt); + } + + [Fact] + public void Integration_SecretsMockVulnerability_FixAndViewDetails_BothBuild() + { + var vulnerabilities = CxAssistMockData.GetSecretsPyMockVulnerabilities(); + var first = vulnerabilities.First(v => CxAssistConstants.IsProblem(v.Severity)); + + var fixPrompt = CxOneAssistFixPrompts.BuildForVulnerability(first); + var viewPrompt = ViewDetailsPrompts.BuildForVulnerability(first); + + Assert.NotNull(fixPrompt); + Assert.NotNull(viewPrompt); + Assert.Contains("secret", fixPrompt.ToLower()); + Assert.Contains("secret", viewPrompt.ToLower()); + } + + [Fact] + public void Integration_IacMockVulnerability_FixAndViewDetails_BothBuild() + { + var vulnerabilities = CxAssistMockData.GetIacMockVulnerabilities(@"C:\src\main.tf"); + var first = vulnerabilities.First(v => CxAssistConstants.IsProblem(v.Severity)); + + var fixPrompt = CxOneAssistFixPrompts.BuildForVulnerability(first); + var viewPrompt = ViewDetailsPrompts.BuildForVulnerability(first); + + Assert.NotNull(fixPrompt); + Assert.NotNull(viewPrompt); + Assert.Contains("IaC", fixPrompt); + } + + [Fact] + public void Integration_ContainerMockVulnerability_FixAndViewDetails_BothBuild() + { + var vulnerabilities = CxAssistMockData.GetContainerMockVulnerabilities(); + var first = vulnerabilities.First(v => CxAssistConstants.IsProblem(v.Severity)); + + var fixPrompt = CxOneAssistFixPrompts.BuildForVulnerability(first); + var viewPrompt = ViewDetailsPrompts.BuildForVulnerability(first); + + Assert.NotNull(fixPrompt); + Assert.NotNull(viewPrompt); + } + + #endregion + + #region IssuesUpdated event + snapshot + + [Fact] + public void Integration_SetFindingsByFile_RaisesIssuesUpdated_WithSnapshotMatchingGetAllIssuesByFile() + { + ClearCoordinator(); + var path = @"C:\src\file.cs"; + var list = new List + { + new Vulnerability("V1", "Title", "Desc", SeverityLevel.High, ScannerType.OSS, 1, 1, path) + }; + var byFile = new Dictionary> { { path, list } }; + IReadOnlyDictionary> eventSnapshot = null; + void Handler(System.Collections.Generic.IReadOnlyDictionary> snapshot) => eventSnapshot = snapshot; + + CxAssistDisplayCoordinator.IssuesUpdated += Handler; + try + { + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + Assert.NotNull(eventSnapshot); + Assert.Single(eventSnapshot); + Assert.True(eventSnapshot.ContainsKey(path)); + Assert.Single(eventSnapshot[path]); + Assert.Equal("V1", eventSnapshot[path][0].Id); + + var getAll = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.Equal(eventSnapshot.Count, getAll.Count); + } + finally + { + CxAssistDisplayCoordinator.IssuesUpdated -= Handler; + } + } + + #endregion + + #region TreeBuilder + VulnerabilityNode display text + + [Fact] + public void Integration_CommonMockData_TreeBuilder_VulnerabilityNodeDisplayText_ContainsLineAndColumn() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + var firstNode = fileNodes[0].Vulnerabilities[0]; + Assert.NotNull(firstNode.DisplayText); + Assert.Contains("Ln", firstNode.DisplayText); + Assert.Contains("Col", firstNode.DisplayText); + Assert.Contains(CxAssistConstants.DisplayName, firstNode.DisplayText); + } + + [Fact] + public void Integration_CommonMockData_TreeBuilder_FileNodesOrderedByFilePath() + { + var path1 = @"C:\src\a.cs"; + var path2 = @"C:\src\b.cs"; + var v1 = new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, path1) }; + var v2 = new List { new Vulnerability("V2", "T", "D", SeverityLevel.Medium, ScannerType.OSS, 1, 1, path2) }; + var combined = v1.Concat(v2).ToList(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(combined); + + Assert.Equal(2, fileNodes.Count); + Assert.True(fileNodes[0].FilePath.CompareTo(fileNodes[1].FilePath) <= 0); + } + + [Fact] + public void Integration_CoordinatorClear_ThenSetAgain_ReflectsNewData() + { + ClearCoordinator(); + var path = @"C:\src\file.cs"; + var first = new Dictionary> + { + { path, new List { new Vulnerability("V1", "T1", "D1", SeverityLevel.High, ScannerType.OSS, 1, 1, path) } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(first); + Assert.NotNull(CxAssistDisplayCoordinator.FindVulnerabilityById("V1")); + + ClearCoordinator(); + Assert.Null(CxAssistDisplayCoordinator.GetCurrentFindings()); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById("V1")); + + var second = new Dictionary> + { + { path, new List { new Vulnerability("V2", "T2", "D2", SeverityLevel.Medium, ScannerType.ASCA, 2, 1, path) } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(second); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById("V1")); + Assert.NotNull(CxAssistDisplayCoordinator.FindVulnerabilityById("V2")); + } + + [Fact] + public void Integration_RequirementsMock_ToTreeBuilder_OnlyProblemSeveritiesInTree() + { + var vulnerabilities = CxAssistMockData.GetRequirementsMockVulnerabilities(@"C:\src\requirements.txt"); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + var problemCount = vulnerabilities.Count(v => CxAssistConstants.IsProblem(v.Severity)); + var treeCount = fileNodes.Sum(f => f.Vulnerabilities.Count); + Assert.Equal(problemCount, treeCount); + } + + [Fact] + public void Integration_EveryMockDataSource_BuildsValidTreeOrEmpty() + { + var path = @"C:\test\file"; + var sources = new List> + { + CxAssistMockData.GetCommonVulnerabilities(path), + CxAssistMockData.GetPackageJsonMockVulnerabilities(path + ".json"), + CxAssistMockData.GetPomMockVulnerabilities(path + ".xml"), + CxAssistMockData.GetSecretsPyMockVulnerabilities(path + ".py"), + CxAssistMockData.GetRequirementsMockVulnerabilities(path + ".txt"), + CxAssistMockData.GetIacMockVulnerabilities(path + ".tf"), + CxAssistMockData.GetContainerMockVulnerabilities(path), + CxAssistMockData.GetDockerComposeMockVulnerabilities(path + ".yml"), + CxAssistMockData.GetGoModMockVulnerabilities(path + ".mod"), + CxAssistMockData.GetCsprojMockVulnerabilities(path + ".csproj") + }; + + foreach (var list in sources) + { + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(list); + Assert.NotNull(fileNodes); + var problemCount = list.Count(v => CxAssistConstants.IsProblem(v.Severity)); + var treeCount = fileNodes.Sum(f => f.Vulnerabilities.Count); + Assert.True(treeCount <= problemCount, "Same-line grouping can reduce tree node count."); + Assert.True(problemCount == 0 || treeCount > 0, "At least one tree node when there are problem findings."); + } + } + + #endregion + + #region File-based integration (test-data layout) + + /// + /// Resolves test-data path when running from test output (test-data is CopyToOutputDirectory). + /// + private static string GetTestDataPath(string relativePath) + { + var baseDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly()?.Location); + if (string.IsNullOrEmpty(baseDir)) return null; + return Path.Combine(baseDir, "test-data", relativePath); + } + + [Fact] + public void Integration_TestDataManifestFiles_RecognizedByScannerConstants() + { + var packageJsonPath = GetTestDataPath("package.json"); + var pomPath = GetTestDataPath("pom.xml"); + if (!string.IsNullOrEmpty(packageJsonPath)) + Assert.True(CxAssistScannerConstants.IsManifestFile(packageJsonPath)); + if (!string.IsNullOrEmpty(pomPath)) + Assert.True(CxAssistScannerConstants.IsManifestFile(pomPath)); + } + + [Fact] + public void Integration_TestDataContainerAndIacFiles_RecognizedByScannerConstants() + { + var dockerfilePath = GetTestDataPath("Dockerfile"); + var valuesYamlPath = GetTestDataPath("values.yaml"); + if (!string.IsNullOrEmpty(dockerfilePath)) + { + Assert.True(CxAssistScannerConstants.IsContainersFile(dockerfilePath)); + Assert.True(CxAssistScannerConstants.IsDockerFile(dockerfilePath)); + Assert.True(CxAssistScannerConstants.IsIacFile(dockerfilePath)); + } + if (!string.IsNullOrEmpty(valuesYamlPath)) + Assert.True(CxAssistScannerConstants.IsIacFile(valuesYamlPath)); + } + + [Fact] + public void Integration_TestDataSecretsFile_NotExcludedForSecrets() + { + var secretsPath = GetTestDataPath("secrets.py"); + if (string.IsNullOrEmpty(secretsPath)) return; + Assert.False(CxAssistScannerConstants.IsManifestFile(secretsPath)); + Assert.False(CxAssistScannerConstants.IsExcludedForSecrets(secretsPath)); + } + + [Fact] + public void Integration_TestDataPackageJson_PassesBaseScanCheckAndIsManifest() + { + var path = GetTestDataPath("package.json"); + if (string.IsNullOrEmpty(path)) return; + Assert.True(CxAssistScannerConstants.PassesBaseScanCheck(path)); + Assert.True(CxAssistScannerConstants.IsManifestFile(path)); + } + + [Fact] + public void Integration_TestDataYamlFiles_RecognizedAsIac() + { + var valuesPath = GetTestDataPath("values.yaml"); + var negativePath = GetTestDataPath("negative1.yaml"); + if (!string.IsNullOrEmpty(valuesPath)) + Assert.True(CxAssistScannerConstants.IsIacFile(valuesPath)); + if (!string.IsNullOrEmpty(negativePath)) + Assert.True(CxAssistScannerConstants.IsIacFile(negativePath)); + } + + #endregion + + #region Coordinator + FindVulnerabilityByLocation (multiple lines) + + [Fact] + public void Integration_Coordinator_FindVulnerabilityByLocation_EachLineReturnsCorrectVulnerability() + { + ClearCoordinator(); + var path = @"C:\src\app.cs"; + var list = new List + { + new Vulnerability("V1", "T1", "D1", SeverityLevel.High, ScannerType.ASCA, 5, 1, path), + new Vulnerability("V2", "T2", "D2", SeverityLevel.Medium, ScannerType.ASCA, 10, 1, path), + new Vulnerability("V3", "T3", "D3", SeverityLevel.Low, ScannerType.ASCA, 15, 1, path) + }; + var byFile = new Dictionary> { { path, list } }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + + var atLine5 = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 4); + var atLine10 = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 9); + var atLine15 = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(path, 14); + + Assert.NotNull(atLine5); + Assert.Equal("V1", atLine5.Id); + Assert.NotNull(atLine10); + Assert.Equal("V2", atLine10.Id); + Assert.NotNull(atLine15); + Assert.Equal("V3", atLine15.Id); + } + + [Fact] + public void Integration_Coordinator_TwoFiles_FindVulnerabilityByLocation_RespectsDocumentPath() + { + ClearCoordinator(); + var pathA = @"C:\src\a.cs"; + var pathB = @"C:\src\b.cs"; + var byFile = new Dictionary> + { + { pathA, new List { new Vulnerability("VA", "TA", "DA", SeverityLevel.High, ScannerType.OSS, 1, 1, pathA) } }, + { pathB, new List { new Vulnerability("VB", "TB", "DB", SeverityLevel.Medium, ScannerType.OSS, 1, 1, pathB) } } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + + var foundA = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(pathA, 0); + var foundB = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(pathB, 0); + + Assert.NotNull(foundA); + Assert.Equal("VA", foundA.Id); + Assert.NotNull(foundB); + Assert.Equal("VB", foundB.Id); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityByLocation(pathA, 99)); + } + + #endregion + + #region TreeBuilder + SeverityCounts and display + + [Fact] + public void Integration_CommonMockData_TreeBuilder_SeverityCountsReflectVulnerabilities() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + var problemSeverities = vulnerabilities + .Where(v => CxAssistConstants.IsProblem(v.Severity)) + .Select(v => v.Severity.ToString()) + .Distinct() + .ToList(); + var countSeverities = fileNodes[0].SeverityCounts.Select(c => c.Severity).ToList(); + + foreach (var sev in problemSeverities) + Assert.Contains(sev, countSeverities); + } + + [Fact] + public void Integration_CommonMockData_TreeBuilder_PrimaryDisplayText_FormattedPerScanner() + { + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulnerabilities); + + foreach (var node in fileNodes[0].Vulnerabilities) + { + Assert.False(string.IsNullOrEmpty(node.PrimaryDisplayText)); + Assert.True( + node.PrimaryDisplayText.Contains("package") || + node.PrimaryDisplayText.Contains("secret") || + node.PrimaryDisplayText.Contains("container") || + node.PrimaryDisplayText.Contains("detected on this line") || + node.Description != null); + } + } + + [Fact] + public void Integration_TreeBuilder_NullFilePath_UsesDefaultFilePath() + { + var vulns = new List + { + new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.ASCA, 1, 1, null) + }; + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(fileNodes); + Assert.Equal(FindingsTreeBuilder.DefaultFilePath, fileNodes[0].FilePath); + Assert.Equal(FindingsTreeBuilder.DefaultFilePath, fileNodes[0].FileName); + } + + [Fact] + public void Integration_TreeBuilder_CustomDefaultFilePath_UsedForNullPath() + { + var vulns = new List + { + new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, null) + }; + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, defaultFilePath: "custom.cs"); + + Assert.Single(fileNodes); + Assert.Equal("custom.cs", fileNodes[0].FilePath); + } + + #endregion + + #region Multi-scanner in one file + + [Fact] + public void Integration_OneFile_MixedScanners_CoordinatorAndTreeBuilder_AllPresent() + { + ClearCoordinator(); + var path = @"C:\src\mixed.cs"; + var list = new List + { + new Vulnerability("V1", "OSS", "D1", SeverityLevel.High, ScannerType.OSS, 1, 1, path) { PackageName = "pkg", PackageVersion = "1.0" }, + new Vulnerability("V2", "ASCA", "D2", SeverityLevel.Medium, ScannerType.ASCA, 5, 1, path) { RuleName = "R1" }, + new Vulnerability("V3", "Secret", "D3", SeverityLevel.Critical, ScannerType.Secrets, 10, 1, path) + }; + var byFile = new Dictionary> { { path, list } }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + Assert.NotNull(current); + Assert.Equal(3, current.Count); + + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(current); + Assert.Single(fileNodes); + Assert.Equal(3, fileNodes[0].Vulnerabilities.Count); + + Assert.NotNull(CxAssistDisplayCoordinator.FindVulnerabilityById("V1")); + Assert.NotNull(CxAssistDisplayCoordinator.FindVulnerabilityById("V2")); + Assert.NotNull(CxAssistDisplayCoordinator.FindVulnerabilityById("V3")); + } + + [Fact] + public void Integration_OneFile_SameLineMultipleScanners_TreeOrderedByLineThenColumn() + { + var path = @"C:\src\same.cs"; + var list = new List + { + new Vulnerability("V1", "First", "D1", SeverityLevel.High, ScannerType.ASCA, 7, 10, path), + new Vulnerability("V2", "Second", "D2", SeverityLevel.Medium, ScannerType.ASCA, 7, 5, path) + }; + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(list); + + Assert.Single(fileNodes); + // ASCA groups by line: multiple findings on same line → one node (highest severity shown). + Assert.Equal(1, fileNodes[0].Vulnerabilities.Count); + Assert.Equal(7, fileNodes[0].Vulnerabilities[0].Line); + } + + #endregion + + #region Prompts + same-line OSS + + [Fact] + public void Integration_OssVulnerability_ViewDetailsWithSameLineVulns_ContainsCveList() + { + var v = new Vulnerability + { + Id = "V1", + Scanner = ScannerType.OSS, + PackageName = "lodash", + PackageVersion = "4.17.19", + Severity = SeverityLevel.High, + CveName = "CVE-2021-001", + Description = "Issue 1" + }; + var sameLine = new List + { + v, + new Vulnerability { CveName = "CVE-2021-002", Severity = SeverityLevel.Medium, Description = "Issue 2" } + }; + + var prompt = ViewDetailsPrompts.BuildForVulnerability(v, sameLine); + + Assert.NotNull(prompt); + Assert.Contains("CVE-2021-001", prompt); + Assert.Contains("CVE-2021-002", prompt); + } + + [Fact] + public void Integration_EveryScannerType_FromMockData_FixAndViewDetailsBothProducePrompt() + { + var path = @"C:\test\file"; + var oss = CxAssistMockData.GetCommonVulnerabilities(path).First(v => v.Scanner == ScannerType.OSS && CxAssistConstants.IsProblem(v.Severity)); + var asca = CxAssistMockData.GetCommonVulnerabilities(path).First(v => v.Scanner == ScannerType.ASCA); + var secrets = CxAssistMockData.GetSecretsPyMockVulnerabilities(path + ".py").First(v => CxAssistConstants.IsProblem(v.Severity)); + var iac = CxAssistMockData.GetIacMockVulnerabilities(path + ".tf").First(v => CxAssistConstants.IsProblem(v.Severity)); + var containers = CxAssistMockData.GetContainerMockVulnerabilities(path).First(v => CxAssistConstants.IsProblem(v.Severity)); + + Assert.NotNull(CxOneAssistFixPrompts.BuildForVulnerability(oss)); + Assert.NotNull(ViewDetailsPrompts.BuildForVulnerability(oss)); + Assert.NotNull(CxOneAssistFixPrompts.BuildForVulnerability(asca)); + Assert.NotNull(ViewDetailsPrompts.BuildForVulnerability(asca)); + Assert.NotNull(CxOneAssistFixPrompts.BuildForVulnerability(secrets)); + Assert.NotNull(ViewDetailsPrompts.BuildForVulnerability(secrets)); + Assert.NotNull(CxOneAssistFixPrompts.BuildForVulnerability(iac)); + Assert.NotNull(ViewDetailsPrompts.BuildForVulnerability(iac)); + Assert.NotNull(CxOneAssistFixPrompts.BuildForVulnerability(containers)); + Assert.NotNull(ViewDetailsPrompts.BuildForVulnerability(containers)); + } + + #endregion + + #region Coordinator edge cases + + [Fact] + public void Integration_Coordinator_EmptyFindings_GetCurrentFindingsNull_FindByIdNull() + { + ClearCoordinator(); + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary>()); + + Assert.Null(CxAssistDisplayCoordinator.GetCurrentFindings()); + Assert.Null(CxAssistDisplayCoordinator.FindVulnerabilityById("any")); + var all = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + Assert.NotNull(all); + Assert.Empty(all); + } + + [Fact] + public void Integration_Coordinator_SetFindingsTwice_IssuesUpdatedSnapshotReflectsSecondSet() + { + ClearCoordinator(); + var path = @"C:\src\file.cs"; + IReadOnlyDictionary> lastSnapshot = null; + void Handler(System.Collections.Generic.IReadOnlyDictionary> s) => lastSnapshot = s; + + CxAssistDisplayCoordinator.IssuesUpdated += Handler; + try + { + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary> + { + { path, new List { new Vulnerability("V1", "T1", "D1", SeverityLevel.High, ScannerType.OSS, 1, 1, path) } } + }); + Assert.NotNull(lastSnapshot); + Assert.Single(lastSnapshot[path]); + Assert.Equal("V1", lastSnapshot[path][0].Id); + + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary> + { + { path, new List { new Vulnerability("V2", "T2", "D2", SeverityLevel.Medium, ScannerType.ASCA, 2, 1, path) } } + }); + Assert.Single(lastSnapshot[path]); + Assert.Equal("V2", lastSnapshot[path][0].Id); + } + finally + { + CxAssistDisplayCoordinator.IssuesUpdated -= Handler; + } + } + + [Fact] + public void Integration_Coordinator_GetAllIssuesByFile_ReturnsIndependentSnapshot() + { + ClearCoordinator(); + var path = @"C:\src\file.cs"; + var list = new List { new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, path) }; + CxAssistDisplayCoordinator.SetFindingsByFile(new Dictionary> { { path, list } }); + + var snap1 = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + var snap2 = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + + Assert.NotSame(snap1, snap2); + Assert.Equal(snap1.Count, snap2.Count); + } + + #endregion + + #region TreeBuilder + callbacks + + [Fact] + public void Integration_TreeBuilder_WithSeverityIconCallback_InvokedForEachVulnerabilityNode() + { + var vulns = CxAssistMockData.GetCommonVulnerabilities(); + var invokedSeverities = new List(); + + FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, loadSeverityIcon: sev => + { + invokedSeverities.Add(sev); + return null; + }); + + var problemCount = vulns.Count(v => CxAssistConstants.IsProblem(v.Severity)); + Assert.True(invokedSeverities.Count >= problemCount); + Assert.Contains("High", invokedSeverities); + Assert.Contains("Critical", invokedSeverities); + } + + [Fact] + public void Integration_TreeBuilder_WithFileIconCallback_InvokedPerFile() + { + var path1 = @"C:\src\a.cs"; + var path2 = @"C:\src\b.cs"; + var combined = new List + { + new Vulnerability("V1", "T", "D", SeverityLevel.High, ScannerType.OSS, 1, 1, path1), + new Vulnerability("V2", "T", "D", SeverityLevel.Medium, ScannerType.OSS, 1, 1, path2) + }; + var invokedPaths = new List(); + + FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(combined, loadFileIcon: p => + { + invokedPaths.Add(p); + return null; + }); + + Assert.Equal(2, invokedPaths.Count); + Assert.Contains(path1, invokedPaths); + Assert.Contains(path2, invokedPaths); + } + + #endregion + + #region DockerCompose + ContainerImage mock flows + + [Fact] + public void Integration_DockerComposeMock_ToTreeBuilder_ProducesContainersNodes() + { + var vulns = CxAssistMockData.GetDockerComposeMockVulnerabilities(@"C:\src\docker-compose.yml"); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.NotNull(fileNodes); + if (fileNodes.Count > 0) + Assert.All(fileNodes.SelectMany(f => f.Vulnerabilities), v => Assert.Equal(ScannerType.Containers, v.Scanner)); + } + + [Fact] + public void Integration_ContainerImageMock_ToCoordinator_ThenTreeBuilder_Consistent() + { + ClearCoordinator(); + var path = @"C:\src\values.yaml"; + var vulns = CxAssistMockData.GetContainerImageMockVulnerabilities(path); + var byFile = new Dictionary> { { path, vulns } }; + + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + var fileNodes = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(current ?? new List()); + + var problemCount = vulns.Count(v => CxAssistConstants.IsProblem(v.Severity)); + var treeCount = fileNodes.Sum(f => f.Vulnerabilities.Count); + Assert.True(treeCount <= problemCount, "Container image mock has all findings on same line → one tree node."); + Assert.True(treeCount >= 1, "At least one tree node for problem findings."); + } + + #endregion + + #region BuildFileNodes from GetAllIssuesByFile + + [Fact] + public void Integration_GetAllIssuesByFile_FlattenToCurrent_BuildFileNodes_SameAsFromCurrent() + { + ClearCoordinator(); + var packageJson = CxAssistMockData.GetPackageJsonMockVulnerabilities(@"C:\p\package.json"); + var pom = CxAssistMockData.GetPomMockVulnerabilities(@"C:\p\pom.xml"); + var byFile = new Dictionary> + { + { @"C:\p\package.json", packageJson }, + { @"C:\p\pom.xml", pom } + }; + CxAssistDisplayCoordinator.SetFindingsByFile(byFile); + + var all = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + var flattened = all.Values.SelectMany(list => list).ToList(); + var fileNodesFromAll = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(flattened); + + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + var fileNodesFromCurrent = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(current); + + Assert.Equal(fileNodesFromCurrent.Count, fileNodesFromAll.Count); + Assert.Equal( + fileNodesFromCurrent.Sum(f => f.Vulnerabilities.Count), + fileNodesFromAll.Sum(f => f.Vulnerabilities.Count)); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistMockDataTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistMockDataTests.cs new file mode 100644 index 00000000..120e8047 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistMockDataTests.cs @@ -0,0 +1,322 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for CxAssistMockData (mock vulnerability lists for POC/demo). + /// + public class CxAssistMockDataTests + { + #region Constants + + [Fact] + public void DefaultFilePath_IsProgramCs() + { + Assert.Equal("Program.cs", CxAssistMockData.DefaultFilePath); + } + + [Fact] + public void QuickInfoOnlyVulnerabilityId_IsPoc007() + { + Assert.Equal("POC-007", CxAssistMockData.QuickInfoOnlyVulnerabilityId); + } + + #endregion + + #region GetCommonVulnerabilities + + [Fact] + public void GetCommonVulnerabilities_NullPath_UsesDefaultFilePath() + { + var list = CxAssistMockData.GetCommonVulnerabilities(null); + Assert.NotNull(list); + Assert.All(list, v => Assert.Equal(CxAssistMockData.DefaultFilePath, v.FilePath)); + } + + [Fact] + public void GetCommonVulnerabilities_EmptyPath_UsesDefaultFilePath() + { + var list = CxAssistMockData.GetCommonVulnerabilities(""); + Assert.NotNull(list); + Assert.All(list, v => Assert.Equal(CxAssistMockData.DefaultFilePath, v.FilePath)); + } + + [Fact] + public void GetCommonVulnerabilities_CustomPath_AllUseCustomPath() + { + var path = @"C:\custom\file.cs"; + var list = CxAssistMockData.GetCommonVulnerabilities(path); + Assert.NotNull(list); + Assert.NotEmpty(list); + Assert.All(list, v => Assert.Equal(path, v.FilePath)); + } + + [Fact] + public void GetCommonVulnerabilities_ContainsExpectedSeveritiesAndScanners() + { + var list = CxAssistMockData.GetCommonVulnerabilities(); + var severities = list.Select(v => v.Severity).Distinct().ToList(); + var scanners = list.Select(v => v.Scanner).Distinct().ToList(); + + Assert.Contains(SeverityLevel.Malicious, severities); + Assert.Contains(SeverityLevel.Critical, severities); + Assert.Contains(SeverityLevel.High, severities); + Assert.Contains(SeverityLevel.Medium, severities); + Assert.Contains(SeverityLevel.Low, severities); + Assert.Contains(ScannerType.OSS, scanners); + Assert.Contains(ScannerType.ASCA, scanners); + } + + [Fact] + public void GetCommonVulnerabilities_ContainsQuickInfoOnlyId() + { + var list = CxAssistMockData.GetCommonVulnerabilities(); + var quickInfoOnly = list.Where(v => v.Id == CxAssistMockData.QuickInfoOnlyVulnerabilityId).ToList(); + Assert.NotEmpty(quickInfoOnly); + } + + [Fact] + public void GetCommonVulnerabilities_AllIdsNonEmpty() + { + var list = CxAssistMockData.GetCommonVulnerabilities(); + Assert.All(list, v => Assert.False(string.IsNullOrEmpty(v.Id))); + } + + [Fact] + public void GetCommonVulnerabilities_LineNumbersPositive() + { + var list = CxAssistMockData.GetCommonVulnerabilities(); + Assert.All(list, v => Assert.True(v.LineNumber >= 1)); + } + + #endregion + + #region GetPackageJsonMockVulnerabilities + + [Fact] + public void GetPackageJsonMockVulnerabilities_NullPath_UsesPackageJson() + { + var list = CxAssistMockData.GetPackageJsonMockVulnerabilities(null); + Assert.NotNull(list); + Assert.All(list, v => Assert.Equal("package.json", v.FilePath)); + } + + [Fact] + public void GetPackageJsonMockVulnerabilities_ContainsOssAndOkSeverity() + { + var list = CxAssistMockData.GetPackageJsonMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + Assert.All(list, v => Assert.Equal(ScannerType.OSS, v.Scanner)); + Assert.Contains(list, v => v.Severity == SeverityLevel.Ok); + } + + #endregion + + #region GetPomMockVulnerabilities + + [Fact] + public void GetPomMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetPomMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetPomMockVulnerabilities_CustomPath_AllUseCustomPath() + { + var path = @"C:\project\pom.xml"; + var list = CxAssistMockData.GetPomMockVulnerabilities(path); + Assert.All(list, v => Assert.Equal(path, v.FilePath)); + } + + #endregion + + #region GetSecretsPyMockVulnerabilities + + [Fact] + public void GetSecretsPyMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetSecretsPyMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetSecretsPyMockVulnerabilities_ContainsSecretsAndAsca() + { + var list = CxAssistMockData.GetSecretsPyMockVulnerabilities(); + var secrets = list.Where(v => v.Scanner == ScannerType.Secrets).ToList(); + var asca = list.Where(v => v.Scanner == ScannerType.ASCA).ToList(); + Assert.True(secrets.Count >= 1, "Expected at least one Secrets finding."); + Assert.True(asca.Count >= 1, "Expected at least one ASCA finding."); + Assert.Equal(list.Count, secrets.Count + asca.Count); + } + + #endregion + + #region GetRequirementsMockVulnerabilities + + [Fact] + public void GetRequirementsMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetRequirementsMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + #endregion + + #region GetIacMockVulnerabilities + + [Fact] + public void GetIacMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetIacMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetIacMockVulnerabilities_AllIacScanner() + { + var list = CxAssistMockData.GetIacMockVulnerabilities(); + Assert.All(list, v => Assert.Equal(ScannerType.IaC, v.Scanner)); + } + + #endregion + + #region GetContainerMockVulnerabilities / GetContainerImageMockVulnerabilities + + [Fact] + public void GetContainerMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetContainerMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetContainerMockVulnerabilities_AllContainersScanner() + { + var list = CxAssistMockData.GetContainerMockVulnerabilities(); + Assert.All(list, v => Assert.Equal(ScannerType.Containers, v.Scanner)); + } + + [Fact] + public void GetContainerImageMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetContainerImageMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + #endregion + + #region GetDockerComposeMockVulnerabilities + + [Fact] + public void GetDockerComposeMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetDockerComposeMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + #endregion + + #region GetDirectoryPackagesPropsMockVulnerabilities, GetGoModMockVulnerabilities, GetCsprojMockVulnerabilities + + [Fact] + public void GetDirectoryPackagesPropsMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetDirectoryPackagesPropsMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetGoModMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetGoModMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetCsprojMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetCsprojMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetPackagesConfigMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetPackagesConfigMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetBuildGradleMockVulnerabilities_ReturnsNonEmptyList() + { + var list = CxAssistMockData.GetBuildGradleMockVulnerabilities(); + Assert.NotNull(list); + Assert.NotEmpty(list); + } + + [Fact] + public void GetPomMockVulnerabilities_ContainsMvnPackageManager() + { + var list = CxAssistMockData.GetPomMockVulnerabilities(); + var mvn = list.FirstOrDefault(v => v.PackageManager == "mvn"); + Assert.NotNull(mvn); + } + + [Fact] + public void GetPackageJsonMockVulnerabilities_ContainsNpmPackageManager() + { + var list = CxAssistMockData.GetPackageJsonMockVulnerabilities(); + var npm = list.FirstOrDefault(v => v.PackageManager == "npm"); + Assert.NotNull(npm); + } + + [Fact] + public void GetCommonVulnerabilities_ContainsAtLeastOneWithLocationsOrLineNumber() + { + var list = CxAssistMockData.GetCommonVulnerabilities(); + Assert.All(list, v => Assert.True(v.LineNumber >= 0)); + } + + [Fact] + public void GetCommonVulnerabilities_AllHaveScannerSet() + { + var list = CxAssistMockData.GetCommonVulnerabilities(); + Assert.All(list, v => Assert.True(v.Scanner == ScannerType.OSS || v.Scanner == ScannerType.ASCA)); + } + + [Fact] + public void GetIacMockVulnerabilities_CustomPath_AllUsePath() + { + var path = @"C:\iac\main.tf"; + var list = CxAssistMockData.GetIacMockVulnerabilities(path); + Assert.All(list, v => Assert.Equal(path, v.FilePath)); + } + + [Fact] + public void GetContainerImageMockVulnerabilities_AllContainersScanner() + { + var list = CxAssistMockData.GetContainerImageMockVulnerabilities(); + Assert.All(list, v => Assert.Equal(ScannerType.Containers, v.Scanner)); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistScannerConstantsTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistScannerConstantsTests.cs new file mode 100644 index 00000000..4a0a2bc8 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxAssistScannerConstantsTests.cs @@ -0,0 +1,482 @@ +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for CxAssistScannerConstants (file pattern matching for OSS, Containers, IaC, Secrets, Helm). + /// + public class CxAssistScannerConstantsTests + { + #region NormalizePathForMatching + + [Fact] + public void NormalizePathForMatching_BackslashesConverted() + { + Assert.Equal("C:/src/file.cs", CxAssistScannerConstants.NormalizePathForMatching(@"C:\src\file.cs")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void NormalizePathForMatching_NullOrEmpty_ReturnsAsIs(string input) + { + Assert.Equal(input, CxAssistScannerConstants.NormalizePathForMatching(input)); + } + + [Fact] + public void NormalizePathForMatching_ForwardSlashes_Unchanged() + { + Assert.Equal("C:/src/file.cs", CxAssistScannerConstants.NormalizePathForMatching("C:/src/file.cs")); + } + + #endregion + + #region PassesBaseScanCheck + + [Fact] + public void PassesBaseScanCheck_NormalPath_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.PassesBaseScanCheck(@"C:\project\src\file.cs")); + } + + [Fact] + public void PassesBaseScanCheck_NodeModulesForwardSlash_ReturnsFalse() + { + Assert.False(CxAssistScannerConstants.PassesBaseScanCheck("C:/project/node_modules/pkg/index.js")); + } + + [Fact] + public void PassesBaseScanCheck_NodeModulesBackslash_ReturnsFalse() + { + Assert.False(CxAssistScannerConstants.PassesBaseScanCheck(@"C:\project\node_modules\pkg\index.js")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void PassesBaseScanCheck_NullOrEmpty_ReturnsTrue(string path) + { + Assert.True(CxAssistScannerConstants.PassesBaseScanCheck(path)); + } + + #endregion + + #region IsManifestFile - OSS patterns + + [Theory] + [InlineData("Directory.Packages.props")] + [InlineData("packages.config")] + [InlineData("pom.xml")] + [InlineData("package.json")] + [InlineData("requirements.txt")] + [InlineData("go.mod")] + public void IsManifestFile_KnownManifestFiles_ReturnsTrue(string fileName) + { + Assert.True(CxAssistScannerConstants.IsManifestFile($@"C:\project\{fileName}")); + } + + [Theory] + [InlineData(@"C:\project\MyApp.csproj")] + [InlineData(@"C:\project\src\Lib.csproj")] + public void IsManifestFile_CsprojFiles_ReturnsTrue(string path) + { + Assert.True(CxAssistScannerConstants.IsManifestFile(path)); + } + + [Theory] + [InlineData(@"C:\project\Program.cs")] + [InlineData(@"C:\project\dockerfile")] + [InlineData(@"C:\project\main.tf")] + [InlineData(@"C:\project\app.py")] + public void IsManifestFile_NonManifestFiles_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsManifestFile(path)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsManifestFile_NullOrEmpty_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsManifestFile(path)); + } + + [Fact] + public void IsManifestFile_CaseInsensitive_PomXml() + { + Assert.True(CxAssistScannerConstants.IsManifestFile(@"C:\project\POM.XML")); + } + + [Fact] + public void IsManifestFile_CaseInsensitive_PackageJson() + { + Assert.True(CxAssistScannerConstants.IsManifestFile(@"C:\project\PACKAGE.JSON")); + } + + #endregion + + #region IsContainersFile + + [Theory] + [InlineData("dockerfile")] + [InlineData("Dockerfile")] + [InlineData("dockerfile-prod")] + [InlineData("dockerfile.dev")] + public void IsContainersFile_DockerfileVariants_ReturnsTrue(string fileName) + { + Assert.True(CxAssistScannerConstants.IsContainersFile($@"C:\project\{fileName}")); + } + + [Theory] + [InlineData("docker-compose.yml")] + [InlineData("docker-compose.yaml")] + [InlineData("docker-compose-prod.yml")] + [InlineData("docker-compose-dev.yaml")] + public void IsContainersFile_DockerComposeVariants_ReturnsTrue(string fileName) + { + Assert.True(CxAssistScannerConstants.IsContainersFile($@"C:\project\{fileName}")); + } + + [Theory] + [InlineData(@"C:\project\main.tf")] + [InlineData(@"C:\project\package.json")] + [InlineData(@"C:\project\app.py")] + public void IsContainersFile_NonContainerFiles_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsContainersFile(path)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsContainersFile_NullOrEmpty_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsContainersFile(path)); + } + + #endregion + + #region IsDockerFile + + [Theory] + [InlineData("dockerfile", true)] + [InlineData("Dockerfile", true)] + [InlineData("dockerfile-prod", true)] + [InlineData("docker-compose.yml", false)] + [InlineData("main.tf", false)] + public void IsDockerFile_VariousInputs_ReturnsExpected(string fileName, bool expected) + { + Assert.Equal(expected, CxAssistScannerConstants.IsDockerFile($@"C:\project\{fileName}")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsDockerFile_NullOrEmpty_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsDockerFile(path)); + } + + #endregion + + #region IsDockerComposeFile + + [Theory] + [InlineData("docker-compose.yml", true)] + [InlineData("docker-compose.yaml", true)] + [InlineData("docker-compose-prod.yml", true)] + [InlineData("dockerfile", false)] + [InlineData("package.json", false)] + public void IsDockerComposeFile_VariousInputs_ReturnsExpected(string fileName, bool expected) + { + Assert.Equal(expected, CxAssistScannerConstants.IsDockerComposeFile($@"C:\project\{fileName}")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsDockerComposeFile_NullOrEmpty_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsDockerComposeFile(path)); + } + + #endregion + + #region IsIacFile + + [Theory] + [InlineData("main.tf")] + [InlineData("vars.auto.tfvars")] + [InlineData("prod.terraform.tfvars")] + [InlineData("config.yaml")] + [InlineData("config.yml")] + [InlineData("template.json")] + [InlineData("service.proto")] + public void IsIacFile_IacExtensions_ReturnsTrue(string fileName) + { + Assert.True(CxAssistScannerConstants.IsIacFile($@"C:\project\{fileName}")); + } + + [Fact] + public void IsIacFile_Dockerfile_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsIacFile(@"C:\project\dockerfile")); + } + + [Theory] + [InlineData(@"C:\project\app.cs")] + [InlineData(@"C:\project\main.py")] + [InlineData(@"C:\project\index.js")] + public void IsIacFile_NonIacFiles_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsIacFile(path)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsIacFile_NullOrEmpty_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsIacFile(path)); + } + + #endregion + + #region IsHelmFile + + [Fact] + public void IsHelmFile_YamlUnderHelm_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsHelmFile("C:/project/helm/templates/deployment.yaml")); + } + + [Fact] + public void IsHelmFile_YmlUnderHelm_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsHelmFile("C:/project/charts/helm/values.yml")); + } + + [Fact] + public void IsHelmFile_ChartYaml_ReturnsFalse() + { + Assert.False(CxAssistScannerConstants.IsHelmFile("C:/project/helm/chart.yaml")); + } + + [Fact] + public void IsHelmFile_ChartYml_ReturnsFalse() + { + Assert.False(CxAssistScannerConstants.IsHelmFile("C:/project/helm/chart.yml")); + } + + [Fact] + public void IsHelmFile_YamlNotUnderHelm_ReturnsFalse() + { + Assert.False(CxAssistScannerConstants.IsHelmFile("C:/project/config/deployment.yaml")); + } + + [Fact] + public void IsHelmFile_NonYamlUnderHelm_ReturnsFalse() + { + Assert.False(CxAssistScannerConstants.IsHelmFile("C:/project/helm/readme.md")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void IsHelmFile_NullOrEmpty_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsHelmFile(path)); + } + + #endregion + + #region IsExcludedForSecrets + + [Theory] + [InlineData(@"C:\project\pom.xml")] + [InlineData(@"C:\project\package.json")] + [InlineData(@"C:\project\requirements.txt")] + [InlineData(@"C:\project\MyApp.csproj")] + public void IsExcludedForSecrets_ManifestFiles_ReturnsTrue(string path) + { + Assert.True(CxAssistScannerConstants.IsExcludedForSecrets(path)); + } + + [Fact] + public void IsExcludedForSecrets_CheckmarxIgnoredForwardSlash_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsExcludedForSecrets("C:/project/.vscode/.checkmarxIgnored")); + } + + [Fact] + public void IsExcludedForSecrets_CheckmarxIgnoredTempList_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsExcludedForSecrets(@"C:\project\.vscode\.checkmarxIgnoredTempList")); + } + + [Theory] + [InlineData(@"C:\project\app.cs")] + [InlineData(@"C:\project\config.yaml")] + [InlineData(@"C:\project\main.py")] + public void IsExcludedForSecrets_RegularFiles_ReturnsFalse(string path) + { + Assert.False(CxAssistScannerConstants.IsExcludedForSecrets(path)); + } + + #endregion + + #region ManifestFilePatterns and Constants + + [Fact] + public void ManifestFilePatterns_ContainsExpectedEntries() + { + var patterns = CxAssistScannerConstants.ManifestFilePatterns; + Assert.Contains("Directory.Packages.props", patterns); + Assert.Contains("packages.config", patterns); + Assert.Contains("pom.xml", patterns); + Assert.Contains("package.json", patterns); + Assert.Contains("requirements.txt", patterns); + Assert.Contains("go.mod", patterns); + Assert.Equal(6, patterns.Count); + } + + [Fact] + public void ManifestCsprojSuffix_IsCsproj() + { + Assert.Equal(".csproj", CxAssistScannerConstants.ManifestCsprojSuffix); + } + + [Fact] + public void IacFileExtensions_ContainsExpectedExtensions() + { + var exts = CxAssistScannerConstants.IacFileExtensions; + Assert.Contains("tf", exts); + Assert.Contains("yaml", exts); + Assert.Contains("yml", exts); + Assert.Contains("json", exts); + Assert.Contains("proto", exts); + Assert.Contains("dockerfile", exts); + } + + [Fact] + public void IsManifestFile_GoMod_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsManifestFile(@"C:\project\go.mod")); + } + + [Fact] + public void IsManifestFile_DirectoryPackagesProps_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsManifestFile(@"C:\src\Directory.Packages.props")); + } + + [Fact] + public void IsIacFile_TfExtension_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsIacFile(@"C:\project\main.tf")); + } + + [Fact] + public void IsIacFile_ProtoExtension_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsIacFile(@"C:\project\service.proto")); + } + + [Fact] + public void IsIacFile_JsonExtension_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsIacFile(@"C:\project\template.json")); + } + + [Fact] + public void IsContainersFile_DockerComposeDevYaml_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsContainersFile(@"C:\project\docker-compose-dev.yaml")); + } + + [Fact] + public void DockerfileLiteral_IsDockerfile() + { + Assert.Equal("dockerfile", CxAssistScannerConstants.DockerfileLiteral); + } + + [Fact] + public void DockerComposeLiteral_IsDockerCompose() + { + Assert.Equal("docker-compose", CxAssistScannerConstants.DockerComposeLiteral); + } + + [Fact] + public void IacAutoTfvarsSuffix_IsExpected() + { + Assert.Equal(".auto.tfvars", CxAssistScannerConstants.IacAutoTfvarsSuffix); + } + + [Fact] + public void IacTerraformTfvarsSuffix_IsExpected() + { + Assert.Equal(".terraform.tfvars", CxAssistScannerConstants.IacTerraformTfvarsSuffix); + } + + [Fact] + public void HelmPathSegment_IsExpected() + { + Assert.Equal("/helm/", CxAssistScannerConstants.HelmPathSegment); + } + + [Fact] + public void ContainerHelmExtensions_ContainsYmlAndYaml() + { + var exts = CxAssistScannerConstants.ContainerHelmExtensions; + Assert.Contains("yml", exts); + Assert.Contains("yaml", exts); + } + + [Fact] + public void ContainerHelmExcludedFiles_ContainsChartYamlAndYml() + { + var excluded = CxAssistScannerConstants.ContainerHelmExcludedFiles; + Assert.Contains("chart.yml", excluded); + Assert.Contains("chart.yaml", excluded); + } + + [Fact] + public void NodeModulesPathSegment_IsExpected() + { + Assert.Equal("/node_modules/", CxAssistScannerConstants.NodeModulesPathSegment); + } + + [Fact] + public void IsExcludedForSecrets_BackslashCheckmarxIgnored_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsExcludedForSecrets(@"C:\project\.vscode\.checkmarxIgnored")); + } + + [Fact] + public void IsIacFile_AutoTfvars_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsIacFile(@"C:\project\vars.auto.tfvars")); + } + + [Fact] + public void IsIacFile_TerraformTfvars_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsIacFile(@"C:\project\prod.terraform.tfvars")); + } + + [Fact] + public void IsManifestFile_RequirementsTxt_ReturnsTrue() + { + Assert.True(CxAssistScannerConstants.IsManifestFile(@"C:\project\requirements.txt")); + } + + [Fact] + public void IsManifestFile_DirectoryPackagesProps_CaseInsensitive() + { + Assert.True(CxAssistScannerConstants.IsManifestFile(@"C:\project\directory.packages.props")); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxOneAssistFixPromptsTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxOneAssistFixPromptsTests.cs new file mode 100644 index 00000000..f4af11b7 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/CxOneAssistFixPromptsTests.cs @@ -0,0 +1,421 @@ +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Prompts; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for CxOneAssistFixPrompts (remediation prompt generation for all scanner types). + /// + public class CxOneAssistFixPromptsTests + { + #region BuildForVulnerability - Dispatch + + [Fact] + public void BuildForVulnerability_Null_ReturnsNull() + { + Assert.Null(CxOneAssistFixPrompts.BuildForVulnerability(null)); + } + + [Fact] + public void BuildForVulnerability_OssScanner_ReturnsSCAPrompt() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + PackageName = "lodash", + PackageVersion = "4.17.19", + PackageManager = "npm", + Severity = SeverityLevel.High + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("lodash", result); + Assert.Contains("4.17.19", result); + Assert.Contains("npm", result); + Assert.Contains("High", result); + } + + [Fact] + public void BuildForVulnerability_SecretsScanner_ReturnsSecretPrompt() + { + var v = new Vulnerability + { + Scanner = ScannerType.Secrets, + Title = "generic-api-key", + Description = "API key detected", + Severity = SeverityLevel.Critical + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("generic-api-key", result); + Assert.Contains("secret", result.ToLower()); + } + + [Fact] + public void BuildForVulnerability_ContainersScanner_ReturnsContainerPrompt() + { + var v = new Vulnerability + { + Scanner = ScannerType.Containers, + Title = "nginx", + PackageVersion = "latest", + Severity = SeverityLevel.Critical, + FilePath = @"C:\src\dockerfile" + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("nginx", result); + Assert.Contains("container", result.ToLower()); + } + + [Fact] + public void BuildForVulnerability_IacScanner_ReturnsIACPrompt() + { + var v = new Vulnerability + { + Scanner = ScannerType.IaC, + Title = "Healthcheck Not Set", + Description = "Missing healthcheck", + Severity = SeverityLevel.Medium, + FilePath = @"C:\src\main.tf", + LineNumber = 10, + ExpectedValue = "true", + ActualValue = "false" + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("Healthcheck Not Set", result); + Assert.Contains("IaC", result); + } + + [Fact] + public void BuildForVulnerability_AscaScanner_ReturnsASCAPrompt() + { + var v = new Vulnerability + { + Scanner = ScannerType.ASCA, + RuleName = "sql-injection", + Description = "SQL Injection found", + Severity = SeverityLevel.High, + RemediationAdvice = "Use parameterized queries", + LineNumber = 42 + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("sql-injection", result); + Assert.Contains("parameterized queries", result); + } + + #endregion + + #region SCA Prompt + + [Fact] + public void BuildSCARemediationPrompt_ContainsAgentName() + { + var result = CxOneAssistFixPrompts.BuildSCARemediationPrompt("lodash", "4.17.19", "npm", "High"); + Assert.Contains("Checkmarx One Assist", result); + } + + [Fact] + public void BuildSCARemediationPrompt_ContainsPackageDetails() + { + var result = CxOneAssistFixPrompts.BuildSCARemediationPrompt("express", "4.18.0", "npm", "Critical"); + Assert.Contains("express@4.18.0", result); + Assert.Contains("npm", result); + Assert.Contains("Critical", result); + } + + [Fact] + public void BuildSCARemediationPrompt_ContainsRemediationSteps() + { + var result = CxOneAssistFixPrompts.BuildSCARemediationPrompt("lodash", "4.17.19", "npm", "High"); + Assert.Contains("Step 1", result); + Assert.Contains("Step 2", result); + Assert.Contains("PackageRemediation", result); + } + + #endregion + + #region Secret Prompt + + [Fact] + public void BuildSecretRemediationPrompt_ContainsSecretTitle() + { + var result = CxOneAssistFixPrompts.BuildSecretRemediationPrompt("aws-access-key", "Found AWS key", "Critical"); + Assert.Contains("aws-access-key", result); + Assert.Contains("Critical", result); + } + + [Fact] + public void BuildSecretRemediationPrompt_NullDescription_DoesNotThrow() + { + var result = CxOneAssistFixPrompts.BuildSecretRemediationPrompt("api-key", null, "High"); + Assert.NotNull(result); + } + + #endregion + + #region Containers Prompt + + [Fact] + public void BuildContainersRemediationPrompt_ContainsImageDetails() + { + var result = CxOneAssistFixPrompts.BuildContainersRemediationPrompt("dockerfile", "nginx", "latest", "Critical"); + Assert.Contains("nginx:latest", result); + Assert.Contains("dockerfile", result); + Assert.Contains("imageRemediation", result); + } + + #endregion + + #region IAC Prompt + + [Fact] + public void BuildIACRemediationPrompt_ContainsAllFields() + { + var result = CxOneAssistFixPrompts.BuildIACRemediationPrompt( + "Healthcheck Not Set", "Missing healthcheck", "Medium", "dockerfile", "true", "false", 9); + + Assert.Contains("Healthcheck Not Set", result); + Assert.Contains("Medium", result); + Assert.Contains("dockerfile", result); + Assert.Contains("true", result); + Assert.Contains("false", result); + Assert.Contains("10", result); + } + + [Fact] + public void BuildIACRemediationPrompt_NullLineNumber_ShowsUnknown() + { + var result = CxOneAssistFixPrompts.BuildIACRemediationPrompt( + "Issue", "Desc", "High", "tf", "expected", "actual", null); + + Assert.Contains("[unknown]", result); + } + + #endregion + + #region ASCA Prompt + + [Fact] + public void BuildASCARemediationPrompt_ContainsRuleAndAdvice() + { + var result = CxOneAssistFixPrompts.BuildASCARemediationPrompt( + "sql-injection", "SQL injection detected", "High", "Use parameterized queries", 41); + + Assert.Contains("sql-injection", result); + Assert.Contains("High", result); + Assert.Contains("42", result); + Assert.Contains("codeRemediation", result); + } + + [Fact] + public void BuildASCARemediationPrompt_NullLineNumber_ShowsUnknown() + { + var result = CxOneAssistFixPrompts.BuildASCARemediationPrompt( + "rule", "desc", "Medium", "advice", null); + + Assert.Contains("[unknown]", result); + } + + #endregion + + #region OSS Null Fields Fallback + + [Fact] + public void BuildForVulnerability_OssNullPackageManager_DefaultsToNpm() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + PackageName = "pkg", + PackageVersion = "1.0", + PackageManager = null, + Severity = SeverityLevel.High + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.Contains("npm", result); + } + + [Fact] + public void BuildForVulnerability_OssNullPackageName_FallsBackToTitle() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + Title = "vulnerable-pkg", + PackageName = null, + PackageVersion = "1.0", + Severity = SeverityLevel.Medium + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.Contains("vulnerable-pkg", result); + } + + [Fact] + public void BuildForVulnerability_ContainersNullTitle_FallsBackToPackageNameOrImage() + { + var v = new Vulnerability + { + Scanner = ScannerType.Containers, + Title = null, + PackageName = "nginx", + PackageVersion = "alpine", + Severity = SeverityLevel.High, + FilePath = @"C:\src\Dockerfile" + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.Contains("nginx", result); + Assert.Contains("alpine", result); + } + + [Fact] + public void BuildForVulnerability_ContainersNullFilePath_UsesUnknownFileType() + { + var v = new Vulnerability + { + Scanner = ScannerType.Containers, + Title = "base", + FilePath = null, + Severity = SeverityLevel.Medium + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.Contains("Unknown", result); + } + + [Fact] + public void BuildForVulnerability_IacLineNumberZero_PassesNullLine() + { + var v = new Vulnerability + { + Scanner = ScannerType.IaC, + Title = "Issue", + Description = "Desc", + Severity = SeverityLevel.Low, + FilePath = @"C:\src\main.tf", + LineNumber = 0, + ExpectedValue = "x", + ActualValue = "y" + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.Contains("[unknown]", result); + } + + [Fact] + public void BuildForVulnerability_AscaLineNumberZero_PassesNullLine() + { + var v = new Vulnerability + { + Scanner = ScannerType.ASCA, + RuleName = "rule", + Description = "Desc", + Severity = SeverityLevel.High, + LineNumber = 0 + }; + + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.Contains("[unknown]", result); + } + + [Fact] + public void BuildSCARemediationPrompt_ContainsIssueTypeInstructions() + { + var result = CxOneAssistFixPrompts.BuildSCARemediationPrompt("pkg", "1.0", "npm", "High"); + Assert.Contains("issueType", result); + Assert.Contains("CVE", result); + Assert.Contains("malicious", result); + } + + [Fact] + public void BuildSecretRemediationPrompt_ContainsCodeRemediationStep() + { + var result = CxOneAssistFixPrompts.BuildSecretRemediationPrompt("api-key", "Description", "Critical"); + Assert.Contains("codeRemediation", result); + Assert.Contains("secret", result.ToLower()); + } + + [Fact] + public void BuildForVulnerability_SecretsNullTitle_FallsBackToDescription() + { + var v = new Vulnerability + { + Scanner = ScannerType.Secrets, + Title = null, + Description = "Hardcoded API key", + Severity = SeverityLevel.High + }; + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.NotNull(result); + Assert.Contains("Hardcoded API key", result); + } + + [Fact] + public void BuildForVulnerability_IacNullTitle_FallsBackToRuleName() + { + var v = new Vulnerability + { + Scanner = ScannerType.IaC, + Title = null, + RuleName = "KICS_RULE_1", + Description = "Desc", + Severity = SeverityLevel.Medium, + FilePath = @"C:\src\main.tf", + LineNumber = 5 + }; + var result = CxOneAssistFixPrompts.BuildForVulnerability(v); + Assert.NotNull(result); + Assert.Contains("KICS_RULE_1", result); + } + + [Fact] + public void BuildContainersRemediationPrompt_ContainsStep3Output() + { + var result = CxOneAssistFixPrompts.BuildContainersRemediationPrompt("yaml", "nginx", "latest", "High"); + Assert.Contains("Step 3", result); + Assert.Contains("OUTPUT", result); + } + + [Fact] + public void BuildSCARemediationPrompt_EmptyPackageName_StillBuilds() + { + var result = CxOneAssistFixPrompts.BuildSCARemediationPrompt("", "1.0", "npm", "High"); + Assert.NotNull(result); + Assert.Contains("npm", result); + } + + [Fact] + public void BuildIACRemediationPrompt_ZeroLineNumber_ShowsUnknown() + { + var result = CxOneAssistFixPrompts.BuildIACRemediationPrompt("Issue", "Desc", "Low", "yaml", "exp", "act", 0); + Assert.Contains("1", result); + } + + [Fact] + public void BuildASCARemediationPrompt_NullRemediationAdvice_StillBuilds() + { + var result = CxOneAssistFixPrompts.BuildASCARemediationPrompt("rule", "desc", "High", null, 1); + Assert.NotNull(result); + Assert.Contains("rule", result); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/FindingsTreeBuilderTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/FindingsTreeBuilderTests.cs new file mode 100644 index 00000000..f6db8614 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/FindingsTreeBuilderTests.cs @@ -0,0 +1,565 @@ +using System.Collections.Generic; +using System.Linq; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for FindingsTreeBuilder (tree model construction for Findings window). + /// + public class FindingsTreeBuilderTests + { + #region Null/Empty Input + + [Fact] + public void BuildFileNodes_NullList_ReturnsEmpty() + { + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(null); + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void BuildFileNodes_EmptyList_ReturnsEmpty() + { + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(new List()); + Assert.NotNull(result); + Assert.Empty(result); + } + + #endregion + + #region Non-Problem Severities Filtered Out + + [Theory] + [InlineData(SeverityLevel.Ok)] + [InlineData(SeverityLevel.Unknown)] + [InlineData(SeverityLevel.Ignored)] + public void BuildFileNodes_NonProblemSeverity_ReturnsEmpty(SeverityLevel severity) + { + var vulns = new List + { + new Vulnerability("V1", "Test", "Desc", severity, ScannerType.OSS, 1, 1, @"C:\src\pom.xml") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Empty(result); + } + + #endregion + + #region Single Vulnerability + + [Fact] + public void BuildFileNodes_SingleOssVulnerability_CreatesOneFileNodeOneVulnNode() + { + var vulns = new List + { + new Vulnerability("V1", "CVE-2024-1234", "Test vuln", SeverityLevel.High, ScannerType.OSS, 10, 1, @"C:\src\package.json") + { + PackageName = "lodash", + PackageVersion = "4.17.19" + } + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + var fileNode = result[0]; + Assert.Equal("package.json", fileNode.FileName); + Assert.Equal(@"C:\src\package.json", fileNode.FilePath); + Assert.Single(fileNode.Vulnerabilities); + Assert.Equal("lodash", fileNode.Vulnerabilities[0].PackageName); + Assert.Equal("4.17.19", fileNode.Vulnerabilities[0].PackageVersion); + } + + [Fact] + public void BuildFileNodes_SingleIacVulnerability_CreatesCorrectNode() + { + var vulns = new List + { + new Vulnerability("V1", "Healthcheck Not Set", "Missing healthcheck", SeverityLevel.Medium, ScannerType.IaC, 5, 1, @"C:\src\dockerfile") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Single(result[0].Vulnerabilities); + Assert.Equal(ScannerType.IaC, result[0].Vulnerabilities[0].Scanner); + Assert.Equal("Healthcheck Not Set", result[0].Vulnerabilities[0].Description); + } + + #endregion + + #region IaC Grouping By Line + + [Fact] + public void BuildFileNodes_MultipleIacSameLine_GroupsIntoSingleRow() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.Medium, ScannerType.IaC, 5, 1, @"C:\src\dockerfile"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.High, ScannerType.IaC, 5, 1, @"C:\src\dockerfile"), + new Vulnerability("V3", "Issue3", "Desc3", SeverityLevel.Low, ScannerType.IaC, 5, 1, @"C:\src\dockerfile") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Single(result[0].Vulnerabilities); + Assert.Contains("3 IAC issues detected on this line", result[0].Vulnerabilities[0].Description); + } + + [Fact] + public void BuildFileNodes_IacDifferentLines_CreatesSeparateRows() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.Medium, ScannerType.IaC, 5, 1, @"C:\src\dockerfile"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.High, ScannerType.IaC, 10, 1, @"C:\src\dockerfile") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Equal(2, result[0].Vulnerabilities.Count); + } + + #endregion + + #region Multi-File Grouping + + [Fact] + public void BuildFileNodes_VulnerabilitiesInDifferentFiles_CreatesMultipleFileNodes() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\package.json"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.IaC, 5, 1, @"C:\src\dockerfile") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Equal(2, result.Count); + } + + #endregion + + #region Severity Counts + + [Fact] + public void BuildFileNodes_MultipleSeverities_CreatesSeverityCounts() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\package.json"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.High, ScannerType.OSS, 2, 1, @"C:\src\package.json"), + new Vulnerability("V3", "Issue3", "Desc3", SeverityLevel.Medium, ScannerType.OSS, 3, 1, @"C:\src\package.json") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + var counts = result[0].SeverityCounts; + Assert.True(counts.Count >= 1); + Assert.Contains(counts, c => c.Severity == "High"); + } + + #endregion + + #region Default File Path + + [Fact] + public void BuildFileNodes_NullFilePath_UsesDefaultFilePath() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 1, 1, null) + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Equal(FindingsTreeBuilder.DefaultFilePath, result[0].FilePath); + } + + [Fact] + public void BuildFileNodes_EmptyFilePath_UsesDefaultFilePath() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 1, 1, "") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Equal(FindingsTreeBuilder.DefaultFilePath, result[0].FilePath); + } + + [Fact] + public void BuildFileNodes_CustomDefaultFilePath_IsUsed() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 1, 1, null) + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, defaultFilePath: "custom.cs"); + + Assert.Single(result); + Assert.Equal("custom.cs", result[0].FilePath); + } + + #endregion + + #region Ordering + + [Fact] + public void BuildFileNodes_VulnerabilitiesOrderedByLineThenColumn() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 20, 1, @"C:\src\app.cs"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 5, 1, @"C:\src\app.cs"), + new Vulnerability("V3", "Issue3", "Desc3", SeverityLevel.Low, ScannerType.ASCA, 10, 1, @"C:\src\app.cs") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + var lines = result[0].Vulnerabilities.Select(v => v.Line).ToList(); + Assert.Equal(new[] { 5, 10, 20 }, lines); + } + + #endregion + + #region All Scanner Types + + [Fact] + public void BuildFileNodes_SecretsScanner_CreatesCorrectNode() + { + var vulns = new List + { + new Vulnerability("V1", "generic-api-key", "Detected API key", SeverityLevel.Critical, ScannerType.Secrets, 15, 1, @"C:\src\config.py") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Equal(ScannerType.Secrets, result[0].Vulnerabilities[0].Scanner); + } + + [Fact] + public void BuildFileNodes_ContainersScanner_CreatesCorrectNode() + { + var vulns = new List + { + new Vulnerability("V1", "nginx:latest", "Vulnerable image", SeverityLevel.Critical, ScannerType.Containers, 1, 1, @"C:\src\values.yaml") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Equal(ScannerType.Containers, result[0].Vulnerabilities[0].Scanner); + } + + [Fact] + public void BuildFileNodes_MixedScanners_GroupsByFile() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\file.cs"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 5, 1, @"C:\src\file.cs"), + new Vulnerability("V3", "Issue3", "Desc3", SeverityLevel.Low, ScannerType.Secrets, 10, 1, @"C:\src\file.cs") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Equal(3, result[0].Vulnerabilities.Count); + } + + #endregion + + #region ASCA Grouping By Line + + [Fact] + public void BuildFileNodes_MultipleAscaSameLine_ShowsHighestSeverity() + { + var vulns = new List + { + new Vulnerability("V1", "Rule1", "Low issue", SeverityLevel.Low, ScannerType.ASCA, 10, 1, @"C:\src\app.cs"), + new Vulnerability("V2", "Rule2", "High issue", SeverityLevel.High, ScannerType.ASCA, 10, 1, @"C:\src\app.cs") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + // Multiple ASCA on same line -> only one node shown (highest severity) + Assert.Single(result[0].Vulnerabilities); + } + + #endregion + + #region OSS Grouping By Line + + [Fact] + public void BuildFileNodes_MultipleOssSameLine_ShowsHighestSeverity() + { + var vulns = new List + { + new Vulnerability("V1", "CVE-1", "Desc1", SeverityLevel.Medium, ScannerType.OSS, 5, 1, @"C:\src\package.json") + { PackageName = "lodash", PackageVersion = "4.17.19" }, + new Vulnerability("V2", "CVE-2", "Desc2", SeverityLevel.Critical, ScannerType.OSS, 5, 1, @"C:\src\package.json") + { PackageName = "lodash", PackageVersion = "4.17.19" } + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Single(result[0].Vulnerabilities); + } + + #endregion + + #region Severity Icon Callback + + [Fact] + public void BuildFileNodes_SeverityIconCallback_IsInvoked() + { + var invoked = new List(); + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\package.json") + }; + + FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, loadSeverityIcon: (sev) => + { + invoked.Add(sev); + return null; + }); + + Assert.Contains("High", invoked); + } + + [Fact] + public void BuildFileNodes_NullCallbacks_DoesNotThrow() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\package.json") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, null, null); + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public void BuildFileNodes_FileIconCallback_IsInvokedPerFile() + { + var invokedPaths = new List(); + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.OSS, 1, 1, @"C:\src\package.json"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.IaC, 1, 1, @"C:\src\dockerfile") + }; + + FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, null, (path) => + { + invokedPaths.Add(path); + return null; + }); + + Assert.Equal(2, invokedPaths.Count); + Assert.Contains(@"C:\src\package.json", invokedPaths); + Assert.Contains(@"C:\src\dockerfile", invokedPaths); + } + + [Fact] + public void BuildFileNodes_MultipleSecretsSameLine_ShowsHighestSeverityOnly() + { + var vulns = new List + { + new Vulnerability("V1", "api-key", "Desc1", SeverityLevel.Low, ScannerType.Secrets, 7, 1, @"C:\src\secrets.py"), + new Vulnerability("V2", "api-key", "Desc2", SeverityLevel.Critical, ScannerType.Secrets, 7, 1, @"C:\src\secrets.py") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Single(result[0].Vulnerabilities); + Assert.Equal("Critical", result[0].Vulnerabilities[0].Severity); + } + + [Fact] + public void BuildFileNodes_MultipleContainersSameLine_ShowsHighestSeverityOnly() + { + var vulns = new List + { + new Vulnerability("V1", "nginx", "Desc1", SeverityLevel.Medium, ScannerType.Containers, 1, 1, @"C:\src\Dockerfile"), + new Vulnerability("V2", "nginx", "Desc2", SeverityLevel.High, ScannerType.Containers, 1, 1, @"C:\src\Dockerfile") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + Assert.Single(result[0].Vulnerabilities); + Assert.Equal("High", result[0].Vulnerabilities[0].Severity); + } + + [Fact] + public void BuildFileNodes_OrderingByLineThenColumn_RespectsColumn() + { + // ASCA groups by line (same line → one node). Use different lines to get 3 nodes and assert order by line then column. + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 10, 20, @"C:\src\app.cs"), + new Vulnerability("V2", "Issue2", "Desc2", SeverityLevel.Medium, ScannerType.ASCA, 11, 5, @"C:\src\app.cs"), + new Vulnerability("V3", "Issue3", "Desc3", SeverityLevel.Low, ScannerType.ASCA, 12, 15, @"C:\src\app.cs") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result); + var nodes = result[0].Vulnerabilities; + Assert.Equal(3, nodes.Count); + Assert.Equal(10, nodes[0].Line); + Assert.Equal(11, nodes[1].Line); + Assert.Equal(12, nodes[2].Line); + Assert.Equal(20, nodes[0].Column); + Assert.Equal(5, nodes[1].Column); + Assert.Equal(15, nodes[2].Column); + } + + [Fact] + public void BuildFileNodes_EmptyDefaultFilePath_UsesDefaultFilePathConstant() + { + var vulns = new List + { + new Vulnerability("V1", "Issue1", "Desc1", SeverityLevel.High, ScannerType.ASCA, 1, 1, null) + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns, defaultFilePath: ""); + + Assert.Single(result); + Assert.Equal(FindingsTreeBuilder.DefaultFilePath, result[0].FilePath); + } + + [Fact] + public void BuildFileNodes_IacSingleIssueOnLine_ShowsTitleNotCountMessage() + { + var vulns = new List + { + new Vulnerability("V1", "Healthcheck Not Set", "Missing healthcheck", SeverityLevel.Medium, ScannerType.IaC, 5, 1, @"C:\src\dockerfile") + }; + + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + + Assert.Single(result[0].Vulnerabilities); + Assert.Equal("Healthcheck Not Set", result[0].Vulnerabilities[0].Description); + Assert.DoesNotContain("issues detected on this line", result[0].Vulnerabilities[0].Description); + } + + [Fact] + public void BuildFileNodes_FileNodeWithoutExtension_FileNameUsesPath() + { + var path = @"C:\src\Dockerfile"; + var vulns = new List + { + new Vulnerability("V1", "Issue", "Desc", SeverityLevel.High, ScannerType.Containers, 1, 1, path) + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Single(result); + Assert.Equal("Dockerfile", result[0].FileName); + Assert.Equal(path, result[0].FilePath); + } + + [Fact] + public void BuildFileNodes_AllProblemSeverities_IncludedInTree() + { + var path = @"C:\src\file.cs"; + var vulns = new List + { + new Vulnerability("V1", "T1", "D1", SeverityLevel.Critical, ScannerType.ASCA, 1, 1, path), + new Vulnerability("V2", "T2", "D2", SeverityLevel.Info, ScannerType.ASCA, 2, 1, path), + new Vulnerability("V3", "T3", "D3", SeverityLevel.Malicious, ScannerType.OSS, 3, 1, path) + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Single(result); + Assert.Equal(3, result[0].Vulnerabilities.Count); + } + + [Fact] + public void DefaultFilePath_Constant_IsProgramCs() + { + Assert.Equal("Program.cs", FindingsTreeBuilder.DefaultFilePath); + } + + [Fact] + public void BuildFileNodes_MixedOkAndHigh_InTreeOnlyHigh() + { + var path = @"C:\src\package.json"; + var vulns = new List + { + new Vulnerability("V1", "OK pkg", "No vuln", SeverityLevel.Ok, ScannerType.OSS, 1, 1, path), + new Vulnerability("V2", "High pkg", "Vuln", SeverityLevel.High, ScannerType.OSS, 2, 1, path) + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Single(result); + Assert.Single(result[0].Vulnerabilities); + Assert.Equal("High", result[0].Vulnerabilities[0].Severity); + } + + [Fact] + public void BuildFileNodes_AllOkSeverity_ReturnsEmpty() + { + var vulns = new List + { + new Vulnerability("V1", "OK", "No vuln", SeverityLevel.Ok, ScannerType.OSS, 1, 1, @"C:\src\p.json") + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Empty(result); + } + + [Fact] + public void BuildFileNodes_AllUnknownSeverity_ReturnsEmpty() + { + var vulns = new List + { + new Vulnerability("V1", "Unknown", "Unknown", SeverityLevel.Unknown, ScannerType.OSS, 1, 1, @"C:\src\p.json") + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Empty(result); + } + + [Fact] + public void BuildFileNodes_AllIgnoredSeverity_ReturnsEmpty() + { + var vulns = new List + { + new Vulnerability("V1", "Ignored", "Ignored", SeverityLevel.Ignored, ScannerType.ASCA, 1, 1, @"C:\src\app.cs") + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Empty(result); + } + + [Fact] + public void BuildFileNodes_IacUsesTitleWhenDescriptionNull() + { + var vulns = new List + { + new Vulnerability("V1", "Rule Title", null, SeverityLevel.Medium, ScannerType.IaC, 1, 1, @"C:\src\dockerfile") + }; + var result = FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(vulns); + Assert.Single(result[0].Vulnerabilities); + Assert.Equal("Rule Title", result[0].Vulnerabilities[0].Description); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/FindingsTreeNodeTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/FindingsTreeNodeTests.cs new file mode 100644 index 00000000..395774e4 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/FindingsTreeNodeTests.cs @@ -0,0 +1,436 @@ +using System.Collections.Generic; +using System.ComponentModel; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for FindingsTreeNode, FileNode, VulnerabilityNode, and SeverityCount (INotifyPropertyChanged, display text). + /// + public class FindingsTreeNodeTests + { + #region VulnerabilityNode - PrimaryDisplayText + + [Fact] + public void PrimaryDisplayText_AscaScanner_ReturnsDescription() + { + var node = new VulnerabilityNode { Scanner = ScannerType.ASCA, Description = "SQL Injection found" }; + Assert.Equal("SQL Injection found ", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_OssScanner_ReturnsSeverityRiskPackageFormat() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.OSS, + Severity = "High", + Description = "lodash", + PackageName = "lodash", + PackageVersion = "4.17.19" + }; + Assert.Equal("High-risk package: lodash@4.17.19", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_OssScanner_NullVersion_NoAtSign() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.OSS, + Severity = "Critical", + Description = "axios" + }; + Assert.Equal("Critical-risk package: axios", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_OssScanner_StripsCve() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.OSS, + Severity = "High", + Description = "lodash (CVE-2021-23337)" + }; + Assert.Equal("High-risk package: lodash", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_OssScanner_EmptyDescription_UsesPackageName() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.OSS, + Severity = "Medium", + Description = "", + PackageName = "axios", + PackageVersion = "1.0.0" + }; + Assert.Equal("Medium-risk package: axios@1.0.0", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_OssScanner_NullDescription_UsesPackageName() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.OSS, + Severity = "Low", + PackageName = "pkg", + PackageVersion = null + }; + Assert.Equal("Low-risk package: pkg", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_SecretsScanner_ReturnsSeverityRiskSecretFormat() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.Secrets, + Severity = "Critical", + Description = "generic-api-key" + }; + Assert.Equal("Critical-risk secret: generic-api-key", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_ContainersScanner_ReturnsSeverityRiskImageFormat() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.Containers, + Severity = "Critical", + Description = "nginx:latest" + }; + Assert.Equal("Critical-risk container image: nginx:latest", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_IacScanner_ReturnsDescription() + { + var node = new VulnerabilityNode { Scanner = ScannerType.IaC, Description = "Healthcheck Not Set" }; + Assert.Equal("Healthcheck Not Set ", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_GroupedByLineMessage_ReturnsRawMessage() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.IaC, + Description = "4 IAC issues detected on this line" + }; + Assert.Equal("4 IAC issues detected on this line", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_AscaGroupedByLineMessage_ReturnsRawMessage() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.ASCA, + Description = "3 ASCA violations detected on this line" + }; + Assert.Equal("3 ASCA violations detected on this line", node.PrimaryDisplayText); + } + + #endregion + + #region VulnerabilityNode - SecondaryDisplayText + + [Fact] + public void SecondaryDisplayText_ContainsLineAndColumn() + { + var node = new VulnerabilityNode { Line = 42, Column = 5 }; + Assert.Equal("Checkmarx One Assist [Ln 42, Col 5]", node.SecondaryDisplayText); + } + + #endregion + + #region VulnerabilityNode - DisplayText + + [Fact] + public void DisplayText_CombinesPrimaryAndSecondary() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.ASCA, + Description = "Issue", + Line = 10, + Column = 3 + }; + Assert.Contains("Issue", node.DisplayText); + Assert.Contains("Ln 10", node.DisplayText); + Assert.Contains("Col 3", node.DisplayText); + } + + #endregion + + #region VulnerabilityNode - INotifyPropertyChanged + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnDescriptionChange() + { + var node = new VulnerabilityNode(); + var changedProperties = new List(); + node.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName); + + node.Description = "new value"; + + Assert.Contains("Description", changedProperties); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnSeverityChange() + { + var node = new VulnerabilityNode(); + var changedProperties = new List(); + node.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName); + + node.Severity = "High"; + + Assert.Contains("Severity", changedProperties); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnLineChange() + { + var node = new VulnerabilityNode(); + var changedProperties = new List(); + node.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName); + + node.Line = 42; + + Assert.Contains("Line", changedProperties); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnColumnChange() + { + var node = new VulnerabilityNode(); + var changedProperties = new List(); + node.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName); + + node.Column = 5; + + Assert.Contains("Column", changedProperties); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnFilePathChange() + { + var node = new VulnerabilityNode(); + var changedProperties = new List(); + node.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName); + + node.FilePath = @"C:\new\path.cs"; + + Assert.Contains("FilePath", changedProperties); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnScannerChange() + { + var node = new VulnerabilityNode(); + var changedProperties = new List(); + node.PropertyChanged += (s, e) => changedProperties.Add(e.PropertyName); + + node.Scanner = ScannerType.IaC; + + Assert.Contains("Scanner", changedProperties); + } + + #endregion + + #region FileNode + + [Fact] + public void FileNode_DefaultConstructor_InitializesCollections() + { + var fileNode = new FileNode(); + + Assert.NotNull(fileNode.SeverityCounts); + Assert.NotNull(fileNode.Vulnerabilities); + Assert.Empty(fileNode.SeverityCounts); + Assert.Empty(fileNode.Vulnerabilities); + } + + [Fact] + public void FileNode_PropertyChanged_FiresOnFileNameChange() + { + var node = new FileNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.FileName = "test.cs"; + + Assert.Contains("FileName", changed); + } + + [Fact] + public void FileNode_PropertyChanged_FiresOnFilePathChange() + { + var node = new FileNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.FilePath = @"C:\src\test.cs"; + + Assert.Contains("FilePath", changed); + } + + #endregion + + #region SeverityCount + + [Fact] + public void SeverityCount_PropertyChanged_FiresOnCountChange() + { + var sc = new SeverityCount(); + var changed = new List(); + sc.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + sc.Count = 5; + + Assert.Contains("Count", changed); + } + + [Fact] + public void SeverityCount_PropertyChanged_FiresOnSeverityChange() + { + var sc = new SeverityCount(); + var changed = new List(); + sc.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + sc.Severity = "High"; + + Assert.Contains("Severity", changed); + } + + [Fact] + public void SeverityCount_PropertyChanged_FiresOnIconChange() + { + var sc = new SeverityCount(); + var changed = new List(); + sc.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + sc.Icon = null; + + Assert.Contains("Icon", changed); + } + + [Fact] + public void FileNode_PropertyChanged_FiresOnFileIconChange() + { + var node = new FileNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.FileIcon = null; + + Assert.Contains("FileIcon", changed); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnPackageNameChange() + { + var node = new VulnerabilityNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.PackageName = "lodash"; + + Assert.Contains("PackageName", changed); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnPackageVersionChange() + { + var node = new VulnerabilityNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.PackageVersion = "4.17.19"; + + Assert.Contains("PackageVersion", changed); + } + + [Fact] + public void VulnerabilityNode_PropertyChanged_FiresOnSeverityIconChange() + { + var node = new VulnerabilityNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.SeverityIcon = null; + + Assert.Contains("SeverityIcon", changed); + } + + [Fact] + public void PrimaryDisplayText_EmptyDescriptionAndPackageName_Oss_ShowsEmptyName() + { + var node = new VulnerabilityNode + { + Scanner = ScannerType.OSS, + Severity = "High", + Description = null, + PackageName = null + }; + Assert.Equal("High-risk package: ", node.PrimaryDisplayText); + } + + [Fact] + public void PrimaryDisplayText_IacEmptyDescription_StillFormats() + { + var node = new VulnerabilityNode { Scanner = ScannerType.IaC, Description = "" }; + Assert.Equal("", node.PrimaryDisplayText); + } + + [Fact] + public void SecondaryDisplayText_ZeroLineAndColumn_StillFormats() + { + var node = new VulnerabilityNode { Line = 0, Column = 0 }; + Assert.Contains("Ln 0", node.SecondaryDisplayText); + Assert.Contains("Col 0", node.SecondaryDisplayText); + } + + [Fact] + public void DisplayText_ContainsDisplayName() + { + var node = new VulnerabilityNode { Description = "Test", Line = 1, Column = 1 }; + Assert.Contains(CxAssistConstants.DisplayName, node.DisplayText); + } + + [Fact] + public void FileNode_PropertyChanged_FiresOnSeverityCountsChange() + { + var node = new FileNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.SeverityCounts = new System.Collections.ObjectModel.ObservableCollection(); + + Assert.Contains("SeverityCounts", changed); + } + + [Fact] + public void FileNode_PropertyChanged_FiresOnVulnerabilitiesChange() + { + var node = new FileNode(); + var changed = new List(); + node.PropertyChanged += (s, e) => changed.Add(e.PropertyName); + + node.Vulnerabilities = new System.Collections.ObjectModel.ObservableCollection(); + + Assert.Contains("Vulnerabilities", changed); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/ViewDetailsPromptsTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/ViewDetailsPromptsTests.cs new file mode 100644 index 00000000..1b6c5b4b --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/ViewDetailsPromptsTests.cs @@ -0,0 +1,432 @@ +using System.Collections.Generic; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Prompts; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for ViewDetailsPrompts (explanation prompt generation for all scanner types). + /// + public class ViewDetailsPromptsTests + { + #region BuildForVulnerability - Dispatch + + [Fact] + public void BuildForVulnerability_Null_ReturnsNull() + { + Assert.Null(ViewDetailsPrompts.BuildForVulnerability(null)); + } + + [Fact] + public void BuildForVulnerability_OssScanner_ReturnsSCAExplanation() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + PackageName = "lodash", + PackageVersion = "4.17.19", + Severity = SeverityLevel.High, + CveName = "CVE-2021-23337", + Description = "Prototype pollution" + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("lodash", result); + Assert.Contains("4.17.19", result); + Assert.Contains("High", result); + } + + [Fact] + public void BuildForVulnerability_SecretsScanner_ReturnsSecretsExplanation() + { + var v = new Vulnerability + { + Scanner = ScannerType.Secrets, + Title = "generic-api-key", + Description = "API key found in code", + Severity = SeverityLevel.Critical + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("generic-api-key", result); + Assert.Contains("Critical", result); + Assert.Contains("Do not change any code", result); + } + + [Fact] + public void BuildForVulnerability_ContainersScanner_ReturnsContainersExplanation() + { + var v = new Vulnerability + { + Scanner = ScannerType.Containers, + Title = "nginx", + PackageVersion = "latest", + Severity = SeverityLevel.Critical, + FilePath = @"C:\src\values.yaml" + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("nginx", result); + Assert.Contains("container", result.ToLower()); + } + + [Fact] + public void BuildForVulnerability_IacScanner_ReturnsIACExplanation() + { + var v = new Vulnerability + { + Scanner = ScannerType.IaC, + Title = "Healthcheck Not Set", + Description = "Missing healthcheck", + Severity = SeverityLevel.Medium, + FilePath = @"C:\src\dockerfile", + ExpectedValue = "defined", + ActualValue = "undefined" + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("Healthcheck Not Set", result); + Assert.Contains("Medium", result); + } + + [Fact] + public void BuildForVulnerability_AscaScanner_ReturnsASCAExplanation() + { + var v = new Vulnerability + { + Scanner = ScannerType.ASCA, + RuleName = "sql-injection", + Description = "SQL Injection found", + Severity = SeverityLevel.High + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v); + + Assert.NotNull(result); + Assert.Contains("sql-injection", result); + Assert.Contains("High", result); + } + + #endregion + + #region SCA Explanation Prompt + + [Fact] + public void BuildSCAExplanationPrompt_ContainsAgentName() + { + var vulns = new List + { + new Vulnerability { CveName = "CVE-2021-23337", Severity = SeverityLevel.High, Description = "Prototype pollution" } + }; + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("lodash", "4.17.19", "High", vulns); + Assert.Contains("Checkmarx One Assist", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_ContainsDoNotChangeCode() + { + var vulns = new List(); + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("pkg", "1.0", "Medium", vulns); + Assert.Contains("Do not change anything in the code", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_MaliciousStatus_ShowsMaliciousWarning() + { + var vulns = new List(); + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("evil-pkg", "1.0", "Malicious", vulns); + Assert.Contains("Malicious Package Detected", result); + Assert.Contains("Never install or use this package", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_WithCVEs_ListsThem() + { + var vulns = new List + { + new Vulnerability { CveName = "CVE-2021-001", Severity = SeverityLevel.High, Description = "Issue 1" }, + new Vulnerability { CveName = "CVE-2021-002", Severity = SeverityLevel.Medium, Description = "Issue 2" } + }; + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("pkg", "1.0", "High", vulns); + Assert.Contains("CVE-2021-001", result); + Assert.Contains("CVE-2021-002", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_EmptyVulns_ShowsNoCVEMessage() + { + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("pkg", "1.0", "Medium", new List()); + Assert.Contains("No CVEs were provided", result); + } + + #endregion + + #region Secrets Explanation Prompt + + [Fact] + public void BuildSecretsExplanationPrompt_ContainsRiskBySeverity() + { + var result = ViewDetailsPrompts.BuildSecretsExplanationPrompt("api-key", "Found key", "Critical"); + Assert.Contains("Risk Understanding Based on Severity", result); + Assert.Contains("Critical", result); + } + + [Fact] + public void BuildSecretsExplanationPrompt_NullDescription_DoesNotThrow() + { + var result = ViewDetailsPrompts.BuildSecretsExplanationPrompt("api-key", null, "High"); + Assert.NotNull(result); + } + + #endregion + + #region Containers Explanation Prompt + + [Fact] + public void BuildContainersExplanationPrompt_ContainsImageInfo() + { + var result = ViewDetailsPrompts.BuildContainersExplanationPrompt("dockerfile", "nginx", "1.24", "Critical"); + Assert.Contains("nginx:1.24", result); + Assert.Contains("dockerfile", result); + Assert.Contains("Critical", result); + } + + #endregion + + #region IAC Explanation Prompt + + [Fact] + public void BuildIACExplanationPrompt_ContainsAllFields() + { + var result = ViewDetailsPrompts.BuildIACExplanationPrompt( + "Healthcheck Not Set", "Missing healthcheck", "Medium", "dockerfile", "defined", "undefined"); + + Assert.Contains("Healthcheck Not Set", result); + Assert.Contains("Medium", result); + Assert.Contains("defined", result); + Assert.Contains("undefined", result); + } + + #endregion + + #region ASCA Explanation Prompt + + [Fact] + public void BuildASCAExplanationPrompt_ContainsRuleAndSeverity() + { + var result = ViewDetailsPrompts.BuildASCAExplanationPrompt("sql-injection", "SQL injection found", "High"); + Assert.Contains("sql-injection", result); + Assert.Contains("High", result); + } + + #endregion + + #region Null Field Fallbacks + + [Fact] + public void BuildForVulnerability_OssNullPackageName_FallsBackToTitle() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + Title = "vulnerable-pkg", + PackageName = null, + PackageVersion = "1.0", + Severity = SeverityLevel.High + }; + var result = ViewDetailsPrompts.BuildForVulnerability(v); + Assert.Contains("vulnerable-pkg", result); + } + + [Fact] + public void BuildForVulnerability_ContainersNullFilePath_UsesUnknownFileType() + { + var v = new Vulnerability + { + Scanner = ScannerType.Containers, + Title = "nginx", + FilePath = null, + Severity = SeverityLevel.High + }; + var result = ViewDetailsPrompts.BuildForVulnerability(v); + Assert.Contains("Unknown", result); + } + + [Fact] + public void BuildForVulnerability_IacNullFields_UsesEmptyStrings() + { + var v = new Vulnerability + { + Scanner = ScannerType.IaC, + Title = null, + RuleName = null, + Description = null, + Severity = SeverityLevel.Low, + FilePath = null, + ExpectedValue = null, + ActualValue = null + }; + var result = ViewDetailsPrompts.BuildForVulnerability(v); + Assert.NotNull(result); + } + + #endregion + + #region SameLineVulns Parameter + + [Fact] + public void BuildForVulnerability_OssWithSameLineVulns_PassedToPrompt() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + PackageName = "lodash", + PackageVersion = "4.17.19", + Severity = SeverityLevel.High + }; + var sameLineVulns = new List + { + v, + new Vulnerability { CveName = "CVE-2024-5678", Severity = SeverityLevel.Medium, Description = "Another issue" } + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v, sameLineVulns); + Assert.Contains("CVE-2024-5678", result); + } + + [Fact] + public void BuildForVulnerability_OssSameLineVulnsNull_UsesSingleVulnerability() + { + var v = new Vulnerability + { + Scanner = ScannerType.OSS, + PackageName = "lodash", + PackageVersion = "4.17.19", + Severity = SeverityLevel.High, + CveName = "CVE-2021-001", + Description = "Prototype pollution" + }; + + var result = ViewDetailsPrompts.BuildForVulnerability(v, null); + + Assert.NotNull(result); + Assert.Contains("lodash", result); + Assert.Contains("CVE-2021-001", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_CveNameNull_UsesId() + { + var vulns = new List + { + new Vulnerability { Id = "POC-001", CveName = null, Severity = SeverityLevel.High, Description = "Issue" } + }; + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("pkg", "1.0", "High", vulns); + Assert.Contains("POC-001", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_MoreThan20CVEs_ListsFirst20() + { + var vulns = new List(); + for (int i = 0; i < 25; i++) + vulns.Add(new Vulnerability { CveName = $"CVE-2021-{i:D3}", Severity = SeverityLevel.High, Description = $"Issue {i}" }); + + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("pkg", "1.0", "High", vulns); + + Assert.Contains("CVE-2021-000", result); + Assert.Contains("CVE-2021-019", result); + } + + [Fact] + public void BuildASCAExplanationPrompt_ContainsComprehensiveExplanation() + { + var result = ViewDetailsPrompts.BuildASCAExplanationPrompt("xss", "XSS vulnerability", "High"); + Assert.Contains("xss", result); + Assert.Contains("XSS vulnerability", result); + Assert.Contains("Output Format Guidelines", result); + } + + [Fact] + public void BuildIACExplanationPrompt_NullExpectedActual_StillBuilds() + { + var result = ViewDetailsPrompts.BuildIACExplanationPrompt( + "Issue", "Desc", "Medium", "yaml", "", ""); + Assert.Contains("Issue", result); + Assert.Contains("yaml", result); + } + + [Fact] + public void BuildForVulnerability_SecretsNullTitle_FallsBackToDescription() + { + var v = new Vulnerability + { + Scanner = ScannerType.Secrets, + Title = null, + Description = "Detected secret in code", + Severity = SeverityLevel.Critical + }; + var result = ViewDetailsPrompts.BuildForVulnerability(v); + Assert.NotNull(result); + Assert.Contains("Detected secret in code", result); + } + + [Fact] + public void BuildSCAExplanationPrompt_NullVulnerabilities_DoesNotThrow() + { + var result = ViewDetailsPrompts.BuildSCAExplanationPrompt("pkg", "1.0", "High", null); + Assert.NotNull(result); + Assert.Contains("pkg", result); + } + + [Fact] + public void BuildSecretsExplanationPrompt_ContainsDoNotChangeCode() + { + var result = ViewDetailsPrompts.BuildSecretsExplanationPrompt("api-key", "Found key", "High"); + Assert.Contains("Do not change any code", result); + } + + [Fact] + public void BuildContainersExplanationPrompt_ContainsDoNotChangeCode() + { + var result = ViewDetailsPrompts.BuildContainersExplanationPrompt("dockerfile", "nginx", "latest", "Critical"); + Assert.Contains("Do not change anything", result); + } + + [Fact] + public void BuildForVulnerability_AscaNullRuleName_FallsBackToTitle() + { + var v = new Vulnerability + { + Scanner = ScannerType.ASCA, + RuleName = null, + Title = "Fallback Title", + Description = "Desc", + Severity = SeverityLevel.High + }; + var result = ViewDetailsPrompts.BuildForVulnerability(v); + Assert.NotNull(result); + Assert.Contains("Fallback Title", result); + } + + [Fact] + public void BuildIACExplanationPrompt_EmptyStrings_StillBuilds() + { + var result = ViewDetailsPrompts.BuildIACExplanationPrompt("", "", "Medium", "tf", "", ""); + Assert.NotNull(result); + Assert.Contains("Medium", result); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/VulnerabilityModelTests.cs b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/VulnerabilityModelTests.cs new file mode 100644 index 00000000..59f07ee5 --- /dev/null +++ b/ast-visual-studio-extension-tests/cx-unit-tests/cx-extension-tests/VulnerabilityModelTests.cs @@ -0,0 +1,338 @@ +using System.Collections.Generic; +using Xunit; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension_tests.cx_unit_tests.cx_extension_tests +{ + /// + /// Unit tests for Vulnerability and VulnerabilityLocation model classes. + /// + public class VulnerabilityModelTests + { + #region Vulnerability Constructor + + [Fact] + public void Vulnerability_DefaultConstructor_PropertiesAreDefaults() + { + var v = new Vulnerability(); + + Assert.Null(v.Id); + Assert.Null(v.Title); + Assert.Null(v.Description); + Assert.Equal(SeverityLevel.Malicious, v.Severity); // first enum value + Assert.Equal(ScannerType.OSS, v.Scanner); // first enum value + Assert.Equal(0, v.LineNumber); + Assert.Equal(0, v.ColumnNumber); + Assert.Null(v.FilePath); + } + + [Fact] + public void Vulnerability_ParameterizedConstructor_SetsAllFields() + { + var v = new Vulnerability("V-001", "SQL Injection", "Found SQL injection", SeverityLevel.High, ScannerType.ASCA, 42, 5, @"C:\src\app.cs"); + + Assert.Equal("V-001", v.Id); + Assert.Equal("SQL Injection", v.Title); + Assert.Equal("Found SQL injection", v.Description); + Assert.Equal(SeverityLevel.High, v.Severity); + Assert.Equal(ScannerType.ASCA, v.Scanner); + Assert.Equal(42, v.LineNumber); + Assert.Equal(5, v.ColumnNumber); + Assert.Equal(@"C:\src\app.cs", v.FilePath); + } + + [Fact] + public void Vulnerability_ParameterizedConstructor_AcceptsNullFilePath() + { + var v = new Vulnerability("V-002", "Title", "Desc", SeverityLevel.Medium, ScannerType.OSS, 1, 1, null); + + Assert.Equal("V-002", v.Id); + Assert.Null(v.FilePath); + } + + [Fact] + public void Vulnerability_ParameterizedConstructor_AcceptsEmptyFilePath() + { + var v = new Vulnerability("V-003", "T", "D", SeverityLevel.Low, ScannerType.Secrets, 10, 1, ""); + + Assert.Equal("", v.FilePath); + } + + #endregion + + #region Vulnerability Properties + + [Fact] + public void Vulnerability_OssSpecificFields_CanBeSetAndGet() + { + var v = new Vulnerability + { + PackageName = "lodash", + PackageVersion = "4.17.19", + PackageManager = "npm", + RecommendedVersion = "4.17.21", + CveName = "CVE-2021-23337", + CvssScore = 7.2 + }; + + Assert.Equal("lodash", v.PackageName); + Assert.Equal("4.17.19", v.PackageVersion); + Assert.Equal("npm", v.PackageManager); + Assert.Equal("4.17.21", v.RecommendedVersion); + Assert.Equal("CVE-2021-23337", v.CveName); + Assert.Equal(7.2, v.CvssScore); + } + + [Fact] + public void Vulnerability_IacSpecificFields_CanBeSetAndGet() + { + var v = new Vulnerability + { + ExpectedValue = "\"false\"", + ActualValue = "\"true\"" + }; + + Assert.Equal("\"false\"", v.ExpectedValue); + Assert.Equal("\"true\"", v.ActualValue); + } + + [Fact] + public void Vulnerability_AscaSpecificFields_CanBeSetAndGet() + { + var v = new Vulnerability + { + RuleName = "sql-injection", + RemediationAdvice = "Use parameterized queries" + }; + + Assert.Equal("sql-injection", v.RuleName); + Assert.Equal("Use parameterized queries", v.RemediationAdvice); + } + + [Fact] + public void Vulnerability_SecretsSpecificFields_CanBeSetAndGet() + { + var v = new Vulnerability { SecretType = "aws-access-key" }; + Assert.Equal("aws-access-key", v.SecretType); + } + + [Fact] + public void Vulnerability_CommonFields_CanBeSetAndGet() + { + var v = new Vulnerability + { + FixLink = "https://example.com/fix", + LearnMoreUrl = "https://example.com/learn" + }; + + Assert.Equal("https://example.com/fix", v.FixLink); + Assert.Equal("https://example.com/learn", v.LearnMoreUrl); + } + + #endregion + + #region Vulnerability Locations + + [Fact] + public void Vulnerability_Locations_NullByDefault() + { + var v = new Vulnerability(); + Assert.Null(v.Locations); + } + + [Fact] + public void Vulnerability_Locations_CanSetMultipleLocations() + { + var v = new Vulnerability + { + Locations = new List + { + new VulnerabilityLocation { Line = 10, StartIndex = 4, EndIndex = 20 }, + new VulnerabilityLocation { Line = 11, StartIndex = 0, EndIndex = 15 }, + new VulnerabilityLocation { Line = 12, StartIndex = 8, EndIndex = 30 } + } + }; + + Assert.Equal(3, v.Locations.Count); + Assert.Equal(10, v.Locations[0].Line); + Assert.Equal(4, v.Locations[0].StartIndex); + Assert.Equal(20, v.Locations[0].EndIndex); + } + + #endregion + + #region Vulnerability StartIndex/EndIndex (single-line) + + [Fact] + public void Vulnerability_StartEndIndex_Defaults() + { + var v = new Vulnerability(); + Assert.Equal(0, v.StartIndex); + Assert.Equal(0, v.EndIndex); + } + + [Fact] + public void Vulnerability_StartEndIndex_CanBeSet() + { + var v = new Vulnerability { StartIndex = 5, EndIndex = 25 }; + Assert.Equal(5, v.StartIndex); + Assert.Equal(25, v.EndIndex); + } + + [Fact] + public void Vulnerability_EndLineNumber_DefaultsToZero() + { + var v = new Vulnerability(); + Assert.Equal(0, v.EndLineNumber); + } + + [Fact] + public void Vulnerability_EndLineNumber_CanBeSetForMultiLine() + { + var v = new Vulnerability { LineNumber = 10, EndLineNumber = 15 }; + Assert.Equal(10, v.LineNumber); + Assert.Equal(15, v.EndLineNumber); + } + + #endregion + + #region VulnerabilityLocation + + [Fact] + public void VulnerabilityLocation_DefaultValues() + { + var loc = new VulnerabilityLocation(); + Assert.Equal(0, loc.Line); + Assert.Equal(0, loc.StartIndex); + Assert.Equal(0, loc.EndIndex); + } + + [Fact] + public void VulnerabilityLocation_SetValues() + { + var loc = new VulnerabilityLocation { Line = 42, StartIndex = 10, EndIndex = 50 }; + Assert.Equal(42, loc.Line); + Assert.Equal(10, loc.StartIndex); + Assert.Equal(50, loc.EndIndex); + } + + [Fact] + public void VulnerabilityLocation_CanRepresentMultiLineSpan() + { + var loc1 = new VulnerabilityLocation { Line = 10, StartIndex = 0, EndIndex = 80 }; + var loc2 = new VulnerabilityLocation { Line = 11, StartIndex = 0, EndIndex = 40 }; + var v = new Vulnerability { LineNumber = 10, Locations = new List { loc1, loc2 } }; + + Assert.Equal(2, v.Locations.Count); + Assert.Equal(11, v.Locations[1].Line); + } + + #endregion + + #region SeverityLevel Enum + + [Fact] + public void SeverityLevel_HasExpectedValues() + { + Assert.Equal(0, (int)SeverityLevel.Malicious); + Assert.Equal(1, (int)SeverityLevel.Critical); + Assert.Equal(2, (int)SeverityLevel.High); + Assert.Equal(3, (int)SeverityLevel.Medium); + Assert.Equal(4, (int)SeverityLevel.Low); + Assert.Equal(5, (int)SeverityLevel.Unknown); + Assert.Equal(6, (int)SeverityLevel.Ok); + Assert.Equal(7, (int)SeverityLevel.Ignored); + Assert.Equal(8, (int)SeverityLevel.Info); + } + + #endregion + + #region ScannerType Enum + + [Fact] + public void ScannerType_HasExpectedValues() + { + Assert.Equal(0, (int)ScannerType.OSS); + Assert.Equal(1, (int)ScannerType.Secrets); + Assert.Equal(2, (int)ScannerType.Containers); + Assert.Equal(3, (int)ScannerType.IaC); + Assert.Equal(4, (int)ScannerType.ASCA); + } + + [Fact] + public void Vulnerability_ParameterizedConstructor_DoesNotSetOptionalProperties() + { + var v = new Vulnerability("ID", "Title", "Desc", SeverityLevel.High, ScannerType.OSS, 1, 1, "path"); + Assert.Null(v.PackageName); + Assert.Null(v.CveName); + Assert.Null(v.Locations); + Assert.Equal(0, v.EndLineNumber); + } + + [Fact] + public void Vulnerability_AllSetters_CanBeUsed() + { + var v = new Vulnerability(); + v.Id = "x"; + v.Title = "y"; + v.Severity = SeverityLevel.Critical; + v.Scanner = ScannerType.Secrets; + v.LineNumber = 10; + v.EndLineNumber = 12; + Assert.Equal("x", v.Id); + Assert.Equal("y", v.Title); + Assert.Equal(SeverityLevel.Critical, v.Severity); + Assert.Equal(ScannerType.Secrets, v.Scanner); + Assert.Equal(10, v.LineNumber); + Assert.Equal(12, v.EndLineNumber); + } + + [Fact] + public void VulnerabilityLocation_DefaultConstructor_AllZero() + { + var loc = new VulnerabilityLocation(); + Assert.Equal(0, loc.Line); + Assert.Equal(0, loc.StartIndex); + Assert.Equal(0, loc.EndIndex); + } + + [Fact] + public void Vulnerability_EndIndexAndStartIndex_CanBeSet() + { + var v = new Vulnerability { StartIndex = 10, EndIndex = 50 }; + Assert.Equal(10, v.StartIndex); + Assert.Equal(50, v.EndIndex); + } + + [Fact] + public void Vulnerability_MultipleOptionalFields_AllPersisted() + { + var v = new Vulnerability + { + Id = "id", + PackageManager = "npm", + RecommendedVersion = "2.0.0", + CveName = "CVE-2024-1", + RuleName = "rule1", + RemediationAdvice = "advice", + ExpectedValue = "exp", + ActualValue = "act", + SecretType = "api-key", + FixLink = "https://fix", + LearnMoreUrl = "https://learn" + }; + Assert.Equal("npm", v.PackageManager); + Assert.Equal("2.0.0", v.RecommendedVersion); + Assert.Equal("CVE-2024-1", v.CveName); + Assert.Equal("rule1", v.RuleName); + Assert.Equal("advice", v.RemediationAdvice); + Assert.Equal("exp", v.ExpectedValue); + Assert.Equal("act", v.ActualValue); + Assert.Equal("api-key", v.SecretType); + Assert.Equal("https://fix", v.FixLink); + Assert.Equal("https://learn", v.LearnMoreUrl); + } + + #endregion + } +} diff --git a/ast-visual-studio-extension/CxExtension/Commands/ErrorListContextMenuCommand.cs b/ast-visual-studio-extension/CxExtension/Commands/ErrorListContextMenuCommand.cs new file mode 100644 index 00000000..c438bd75 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/Commands/ErrorListContextMenuCommand.cs @@ -0,0 +1,163 @@ +using System; +using System.Windows; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using EnvDTE; +using EnvDTE80; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using System.ComponentModel.Design; + +namespace ast_visual_studio_extension.CxExtension.Commands +{ + /// + /// Adds Checkmarx One Assist commands to the Error List context menu (right-click). + /// Commands are enabled only when the selected Error List item is a CxAssist finding. + /// Actions: Fix with Checkmarx One Assist, View details, Ignore this vulnerability, Ignore all of this type. + /// + internal sealed class ErrorListContextMenuCommand + { + public const int FixCommandId = 0x0210; + public const int ViewDetailsCommandId = 0x0211; + public const int IgnoreThisCommandId = 0x0212; + public const int IgnoreAllCommandId = 0x0213; + + private static readonly Guid CommandSetGuid = new Guid("b7e8b6e3-8e3e-4e3e-8e3e-8e3e8e3e8e40"); + + private readonly AsyncPackage _package; + private readonly OleMenuCommandService _commandService; + + private ErrorListContextMenuCommand(AsyncPackage package, OleMenuCommandService commandService) + { + _package = package ?? throw new ArgumentNullException(nameof(package)); + _commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); + + AddCommand(FixCommandId, OnFixWithAssist); + AddCommand(ViewDetailsCommandId, OnViewDetails); + AddCommand(IgnoreThisCommandId, OnIgnoreThis, v => CxAssistConstants.GetIgnoreThisLabel(v.Scanner)); + AddCommand(IgnoreAllCommandId, OnIgnoreAll, v => CxAssistConstants.GetIgnoreAllLabel(v.Scanner)); + } + + public static ErrorListContextMenuCommand Instance { get; private set; } + + public static async System.Threading.Tasks.Task InitializeAsync(AsyncPackage package) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService; + if (commandService == null) return; + Instance = new ErrorListContextMenuCommand(package, commandService); + } + + private void AddCommand(int commandId, EventHandler invokeHandler, Func getText = null, Func isVisible = null) + { + var id = new CommandID(CommandSetGuid, commandId); + var cmd = new OleMenuCommand(invokeHandler, id); + cmd.BeforeQueryStatus += (s, e) => + { + var v = GetSelectedCxAssistVulnerability(); + bool visible = v != null && (isVisible == null || isVisible(v)); + cmd.Visible = cmd.Enabled = visible; + if (getText != null && v != null) + cmd.Text = getText(v); + }; + _commandService.AddCommand(cmd); + } + + /// + /// Gets the CxAssist vulnerability for the currently selected Error List item, or null. + /// Uses IVsTaskList2.EnumSelectedItems when available; otherwise matches by DTE ErrorList selection. + /// + private static Vulnerability GetSelectedCxAssistVulnerability() + { + ThreadHelper.ThrowIfNotOnUIThread(); + try + { + var errorList = Package.GetGlobalService(typeof(SVsErrorList)) as IVsTaskList2; + if (errorList != null) + { + if (errorList.EnumSelectedItems(out var enumItems) == 0 && enumItems != null) + { + var items = new IVsTaskItem[1]; + var fetchedArray = new uint[1]; + if (enumItems.Next(1, items, fetchedArray) == 0 && fetchedArray[0] > 0 && items[0] != null) + { + // Selected item may be our ErrorTask (has HelpKeyword, Document, Line) + if (items[0] is ErrorTask et) + { + if (!string.IsNullOrEmpty(et.HelpKeyword) && et.HelpKeyword.StartsWith(CxAssistErrorListSync.HelpKeywordPrefix, StringComparison.OrdinalIgnoreCase)) + { + string id = et.HelpKeyword.Substring(CxAssistErrorListSync.HelpKeywordPrefix.Length).Trim(); + return CxAssistDisplayCoordinator.FindVulnerabilityById(id); + } + if (!string.IsNullOrEmpty(et.Document) && et.Line >= 0) + return CxAssistDisplayCoordinator.FindVulnerabilityByLocation(et.Document, et.Line); + } + } + } + } + + // Fallback: use DTE ErrorList - selected item is often the one with focus + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + if (dte?.ToolWindows?.ErrorList?.ErrorItems != null) + { + var errors = dte.ToolWindows.ErrorList.ErrorItems; + if (errors.Count >= 1) + { + try + { + var first = errors.Item(1); + string file = first.FileName; + int line = first.Line; + if (!string.IsNullOrEmpty(file) && line >= 0) + return CxAssistDisplayCoordinator.FindVulnerabilityByLocation(file, line > 0 ? line - 1 : 0); + } + catch { /* Item might not be accessible */ } + } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "ErrorListContextMenu.GetSelectedCxAssistVulnerability"); + } + return null; + } + + private void OnFixWithAssist(object sender, EventArgs e) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var v = GetSelectedCxAssistVulnerability(); + if (v != null) CxAssistCopilotActions.SendFixWithAssist(v); + }); + } + + private void OnViewDetails(object sender, EventArgs e) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var v = GetSelectedCxAssistVulnerability(); + if (v != null) CxAssistCopilotActions.SendViewDetails(v); + }); + } + + private void OnIgnoreThis(object sender, EventArgs e) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + }); + } + + private void OnIgnoreAll(object sender, EventArgs e) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + }); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/Commands/ShowFindingsWindowCommand.cs b/ast-visual-studio-extension/CxExtension/Commands/ShowFindingsWindowCommand.cs new file mode 100644 index 00000000..4814de6a --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/Commands/ShowFindingsWindowCommand.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Design; +using System.Collections.ObjectModel; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Task = System.Threading.Tasks.Task; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.Commands +{ + /// + /// Command handler to show and populate the CxAssist Findings window + /// + internal sealed class ShowFindingsWindowCommand + { + public const int CommandId = 0x0110; + public static readonly Guid CommandSet = new Guid("a6e8b6e3-8e3e-4e3e-8e3e-8e3e8e3e8e3f"); + + private readonly AsyncPackage package; + + private ShowFindingsWindowCommand(AsyncPackage package, OleMenuCommandService commandService) + { + this.package = package ?? throw new ArgumentNullException(nameof(package)); + commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); + + var menuCommandID = new CommandID(CommandSet, CommandId); + var menuItem = new MenuCommand(this.Execute, menuCommandID); + commandService.AddCommand(menuItem); + } + + public static ShowFindingsWindowCommand Instance { get; private set; } + + private Microsoft.VisualStudio.Shell.IAsyncServiceProvider ServiceProvider => this.package; + + public static async Task InitializeAsync(AsyncPackage package) + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken); + + OleMenuCommandService commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService; + Instance = new ShowFindingsWindowCommand(package, commandService); + } + + private void Execute(object sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + // Show the existing Checkmarx window (not the standalone CxAssistFindingsWindow) + ToolWindowPane window = this.package.FindToolWindow(typeof(CxWindow), 0, true); + if ((null == window) || (null == window.Frame)) + { + throw new NotSupportedException("Cannot create Checkmarx window"); + } + + IVsWindowFrame windowFrame = (IVsWindowFrame)window.Frame; + Microsoft.VisualStudio.ErrorHandler.ThrowOnFailure(windowFrame.Show()); + + // Get the CxWindowControl and switch to CxAssist tab + var cxWindow = window as CxWindow; + if (cxWindow != null && cxWindow.Content is CxWindowControl cxWindowControl) + { + // Switch to the CxAssist Findings tab + cxWindowControl.SwitchToCxAssistTab(); + + // Get the CxAssist Findings Control and populate with test data + var findingsControl = cxWindowControl.GetCxAssistFindingsControl(); + if (findingsControl != null) + { + PopulateTestData(findingsControl); + } + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error showing CxAssist findings: {ex.Message}"); + } + } + + private void PopulateTestData(CxAssistFindingsControl control) + { + if (control == null) return; + + // Use coordinator's current findings (from last UpdateFindings) so problem window matches gutter/underline + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + if (current != null && current.Count > 0) + { + CxAssistDisplayCoordinator.RefreshProblemWindow(control, LoadSeverityIcon, null); + return; + } + + // No current findings (no file opened yet): show empty list. Findings and Error List will show data only after a file with findings is opened (e.g. package.json). + control.SetAllFileNodes(new ObservableCollection()); + } + + /// + /// Detect if Visual Studio is using dark theme + /// + private bool IsDarkTheme() + { + try + { + // Get the VS theme color using PlatformUI + var color = Microsoft.VisualStudio.PlatformUI.VSColorTheme.GetThemedColor(Microsoft.VisualStudio.PlatformUI.EnvironmentColors.ToolWindowBackgroundColorKey); + + // Calculate brightness (simple luminance formula) + int brightness = (int)Math.Sqrt( + color.R * color.R * 0.299 + + color.G * color.G * 0.587 + + color.B * color.B * 0.114); + + // If brightness is less than 128, it's a dark theme + return brightness < 128; + } + catch + { + // Default to dark theme if detection fails + return true; + } + } + + /// + /// Load severity icon based on severity level - uses reference PNG icons with theme support + /// + private System.Windows.Media.ImageSource LoadSeverityIcon(string severity) + { + try + { + // Determine theme folder + string themeFolder = IsDarkTheme() ? "Dark" : "Light"; + + // Build the icon path + string iconName = severity.ToLower(); + string iconPath = $"pack://application:,,,/ast-visual-studio-extension;component/CxExtension/Resources/CxAssist/Icons/{themeFolder}/{iconName}.png"; + + // Load the PNG image + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.UriSource = new Uri(iconPath, UriKind.Absolute); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + bitmap.Freeze(); + + return bitmap; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error loading severity icon for {severity}: {ex.Message}"); + return null; + } + } + + /// + /// Load generic file icon + /// + private System.Windows.Media.ImageSource LoadIcon(string iconName) + { + try + { + // Use existing info icon as placeholder for file icon + var uri = new Uri("pack://application:,,,/ast-visual-studio-extension;component/CxExtension/Resources/info.png", UriKind.Absolute); + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.UriSource = uri; + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + bitmap.Freeze(); // Important for cross-thread access + return bitmap; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error loading file icon: {ex.Message}"); + return null; + } + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/AssistIconLoader.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/AssistIconLoader.cs new file mode 100644 index 00000000..a9082538 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/AssistIconLoader.cs @@ -0,0 +1,322 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.VisualStudio.PlatformUI; +using SharpVectors.Converters; +using SharpVectors.Renderers.Wpf; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Reusable icon and theme utilities for DevAssist (CxAssist). + /// Single place for VS theme detection and loading CxAssist icons (PNG/SVG) so that + /// Quick Info, Gutter, Findings window, and other UI stay consistent and DRY. + /// + internal static class AssistIconLoader + { + /// Base resource path for CxAssist icons (theme subfolder appended). + public const string IconsBasePath = "CxExtension/Resources/CxAssist/Icons"; + + private static bool _popupIconsLogged; + private static string _lastKnownTheme; + private static bool _themeSubscribed; + + /// + /// Raised when VS theme changes. Subscribers (e.g. CxAssistDisplayCoordinator) should + /// re-trigger taggers so gutter icons and Quick Info render with the new theme. + /// Aligned with JetBrains DevAssistInspectionMgr.isThemeChanged + ProblemDescription.reloadIcons(). + /// + public static event Action ThemeChanged; + + /// + /// Subscribes to VSColorTheme.ThemeChanged so icon caches are invalidated and + /// taggers re-triggered on theme switch. Call once at startup (e.g. from TextViewCreated). + /// + public static void EnsureThemeChangeSubscription() + { + if (_themeSubscribed) return; + _themeSubscribed = true; + VSColorTheme.ThemeChanged += OnVsThemeChanged; + } + + private static void OnVsThemeChanged(ThemeChangedEventArgs e) + { + string oldTheme = _lastKnownTheme; + string newTheme = GetCurrentTheme(); + if (string.Equals(oldTheme, newTheme, StringComparison.OrdinalIgnoreCase)) return; + + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.ICONS_RELOADING_FOR_THEME, oldTheme ?? "unknown", newTheme)); + _lastKnownTheme = newTheme; + _popupIconsLogged = false; + + ThemeChanged?.Invoke(); + } + + /// Returns "Dark" or "Light" based on current VS theme. + public static string GetCurrentTheme() + { + try + { + var backgroundColor = VSColorTheme.GetThemedColor(EnvironmentColors.ToolWindowBackgroundColorKey); + double brightness = (0.299 * backgroundColor.R + 0.587 * backgroundColor.G + 0.114 * backgroundColor.B) / 255.0; + return brightness < 0.5 ? CxAssistConstants.ThemeDark : CxAssistConstants.ThemeLight; + } + catch + { + return CxAssistConstants.ThemeDark; + } + } + + /// True when current VS theme is dark. + public static bool IsDarkTheme() + { + return string.Equals(GetCurrentTheme(), CxAssistConstants.ThemeDark, StringComparison.OrdinalIgnoreCase); + } + + /// File name for severity (e.g. SeverityLevel.Critical -> "critical.png"). + public static string GetSeverityIconFileName(SeverityLevel severity) + { + switch (severity) + { + case SeverityLevel.Malicious: return "malicious.png"; + case SeverityLevel.Critical: return "critical.png"; + case SeverityLevel.High: return "high.png"; + case SeverityLevel.Medium: return "medium.png"; + case SeverityLevel.Low: + case SeverityLevel.Info: return "low.png"; + case SeverityLevel.Ok: return "ok.png"; + case SeverityLevel.Unknown: return "unknown.png"; + case SeverityLevel.Ignored: return "ignored.png"; + default: return "unknown.png"; + } + } + + /// Base name for severity (for SVG: "critical", "malicious", etc.). + public static string GetSeverityIconBaseName(string severity) + { + if (string.IsNullOrEmpty(severity)) return "unknown"; + switch (severity.ToLowerInvariant()) + { + case "malicious": return "malicious"; + case "critical": return "critical"; + case "high": return "high"; + case "medium": return "medium"; + case "low": + case "info": return "low"; + case "ok": return "ok"; + case "unknown": return "unknown"; + case "ignored": return "ignored"; + default: return "unknown"; + } + } + + /// Loads a PNG icon from CxAssist Icons/{theme}/{fileName}. Returns null on failure. + public static BitmapImage LoadPngIcon(string theme, string fileName) + { + var packPath = $"pack://application:,,,/ast-visual-studio-extension;component/{IconsBasePath}/{theme}/{fileName}"; + try + { + var uri = new Uri(packPath, UriKind.Absolute); + var streamInfo = Application.GetResourceStream(uri); + if (streamInfo?.Stream != null) + { + using (var ms = new MemoryStream()) + { + streamInfo.Stream.CopyTo(ms); + ms.Position = 0; + var img = new BitmapImage(); + img.BeginInit(); + img.StreamSource = ms; + img.CacheOption = BitmapCacheOption.OnLoad; + img.EndInit(); + img.Freeze(); + return img; + } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, $"AssistIconLoader.LoadPngIcon (pack): {fileName}"); + } + + try + { + var asm = Assembly.GetExecutingAssembly(); + var resourceName = asm.GetManifestResourceNames() + .FirstOrDefault(n => n.Replace('\\', '/').EndsWith($"CxAssist/Icons/{theme}/{fileName}", StringComparison.OrdinalIgnoreCase) + || n.Replace('\\', '.').EndsWith($"CxAssist.Icons.{theme}.{fileName}", StringComparison.OrdinalIgnoreCase)); + if (resourceName != null) + { + using (var stream = asm.GetManifestResourceStream(resourceName)) + { + if (stream != null) + { + var img = new BitmapImage(); + img.BeginInit(); + img.StreamSource = stream; + img.CacheOption = BitmapCacheOption.OnLoad; + img.EndInit(); + img.Freeze(); + return img; + } + } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, $"AssistIconLoader.LoadPngIcon (manifest): {fileName}"); + } + + return null; + } + + /// Loads a PNG icon for the given severity using current theme. Tries Light if Dark fails. + public static BitmapImage LoadSeverityPngIcon(SeverityLevel severity) + { + string theme = GetCurrentTheme(); + string fileName = GetSeverityIconFileName(severity); + var img = LoadPngIcon(theme, fileName); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadPngIcon(CxAssistConstants.ThemeDark, fileName); + return img; + } + + /// Loads a PNG icon for the given severity string (e.g. "critical"). Use when you have string severity from tags. + public static BitmapImage LoadSeverityPngIcon(string severity) + { + string theme = GetCurrentTheme(); + string fileName = GetSeverityIconBaseName(severity) + ".png"; + var img = LoadPngIcon(theme, fileName); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadPngIcon(CxAssistConstants.ThemeDark, fileName); + return img; + } + + /// Loads an SVG icon from CxAssist Icons/{theme}/{iconName}.svg. iconName without extension. + public static ImageSource LoadSvgIcon(string theme, string iconNameWithoutExtension) + { + string fileName = iconNameWithoutExtension.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) + ? iconNameWithoutExtension + : iconNameWithoutExtension + ".svg"; + var packPath = $"pack://application:,,,/ast-visual-studio-extension;component/{IconsBasePath}/{theme}/{fileName}"; + try + { + var iconUri = new Uri(packPath, UriKind.Absolute); + var streamInfo = Application.GetResourceStream(iconUri); + if (streamInfo?.Stream == null) return null; + + var settings = new WpfDrawingSettings + { + IncludeRuntime = true, + TextAsGeometry = false, + OptimizePath = true + }; + using (var stream = streamInfo.Stream) + { + var converter = new FileSvgReader(settings); + var drawing = converter.Read(stream); + if (drawing != null) + { + var drawingImage = new DrawingImage(drawing); + drawingImage.Freeze(); + return drawingImage; + } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, $"AssistIconLoader.LoadSvgIcon: {fileName}"); + } + return null; + } + + /// Loads SVG for severity (e.g. "critical") using current theme. + public static ImageSource LoadSeveritySvgIcon(string severity) + { + string theme = GetCurrentTheme(); + string baseName = GetSeverityIconBaseName(severity); + var img = LoadSvgIcon(theme, baseName); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadSvgIcon(CxAssistConstants.ThemeDark, baseName); + return img; + } + + /// Loads severity icon (JetBrains-style; prefers SVG, fallback PNG). Use for Quick Info and any UI that can show either. + public static ImageSource LoadSeverityIcon(SeverityLevel severity) + { + string currentTheme = GetCurrentTheme(); + + // Log theme change (aligned with JetBrains ProblemDescription.reloadIcons: "RTS: Icons reloading completed.") + if (_lastKnownTheme != null && !string.Equals(_lastKnownTheme, currentTheme, StringComparison.OrdinalIgnoreCase)) + { + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.ICONS_RELOADING_FOR_THEME, _lastKnownTheme, currentTheme)); + } + _lastKnownTheme = currentTheme; + + var img = LoadSeveritySvgIcon(severity.ToString()); + if (img != null) + { + if (!_popupIconsLogged) + { + _popupIconsLogged = true; + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {string.Format(CxAssistConstants.ICONS_LOADED_FOR_THEME, currentTheme)}"); + } + return img; + } + var png = LoadSeverityPngIcon(severity); + return png; + } + + /// Loads badge/logo PNG (e.g. CxAssistConstants.BadgeIconFileName). + public static BitmapImage LoadBadgeIcon() + { + string theme = GetCurrentTheme(); + var img = LoadPngIcon(theme, CxAssistConstants.BadgeIconFileName); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadPngIcon(CxAssistConstants.ThemeDark, CxAssistConstants.BadgeIconFileName); + return img; + } + + /// Loads the package/cube icon for OSS title row (JetBrains-style; prefers SVG, fallback PNG). + public static ImageSource LoadPackageIcon() + { + string theme = GetCurrentTheme(); + var img = LoadSvgIcon(theme, "package"); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadSvgIcon(CxAssistConstants.ThemeDark, "package"); + if (img == null) + { + var png = LoadPngIcon(theme, "package.png"); + if (png == null && theme != CxAssistConstants.ThemeDark) + png = LoadPngIcon(CxAssistConstants.ThemeDark, "package.png"); + return png; + } + return img; + } + + /// Loads the container/image icon for container scan title row (JetBrains card-containers graphic). + public static ImageSource LoadContainerIcon() + { + string theme = GetCurrentTheme(); + var img = LoadSvgIcon(theme, "container"); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadSvgIcon(CxAssistConstants.ThemeDark, "container"); + return img; + } + + /// Loads the star-action icon (JetBrains-style; used for fix/view/ignore actions). + public static ImageSource LoadStarActionIcon() + { + string theme = GetCurrentTheme(); + var img = LoadSvgIcon(theme, "star-action"); + if (img == null && theme != CxAssistConstants.ThemeDark) + img = LoadSvgIcon(CxAssistConstants.ThemeDark, "star-action"); + return img; + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CopilotIntegration.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CopilotIntegration.cs new file mode 100644 index 00000000..c7ddb896 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CopilotIntegration.cs @@ -0,0 +1,1204 @@ +using System; +using System.Diagnostics; +using System.Windows; +using System.Windows.Threading; +using Microsoft.VisualStudio.Shell; +using EnvDTE; +using EnvDTE80; +using System.Windows.Automation; +using Process = System.Diagnostics.Process; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Utility class for integrating with GitHub Copilot Chat in Visual Studio. + /// + /// + /// Since GitHub Copilot does not expose a public API for VS extensions, this + /// implementation uses DTE commands with SendKeys as a non-blocking async chain: + /// + /// + /// + /// Copy prompt to clipboard (safety fallback) + /// Open Copilot Chat via DTE command or keyboard shortcut + /// Start a new chat thread via DTE command (best effort) + /// Re-focus Copilot Chat, paste prompt from clipboard, submit via Enter + + /// + /// + /// + /// Each step is scheduled via at ApplicationIdle + /// priority so the UI thread is never blocked (no Thread.Sleep). This ensures + /// Copilot Chat can fully render between operations. + /// + /// + /// Fallback Behavior: + /// + /// If automation fails at any stage, the prompt remains in the clipboard and + /// the user is notified to paste manually. + /// + /// + internal static class CopilotIntegration + { + // ==================== Configuration Constants ==================== + + /// + /// Timing delays for UI automation. Tuned for typical VS response times. + /// + private static class Timing + { + /// Delay after opening Copilot to allow UI to fully render. + public const int CopilotOpenDelayMs = 1200; + + /// Delay after starting a new thread for UI to settle. + public const int NewThreadDelayMs = 500; + + /// Delay before paste/submit to ensure input field has focus. + public const int PasteDelayMs = 400; + + /// Brief pause between paste and Enter to let VS process clipboard. + public const int PasteSettleMs = 100; + } + + /// + /// UI Automation properties for GitHub Copilot Chat integration. + /// + private static class AutomationProperties + { + public static readonly string[] ModePickerNames = { + "Chat Mode Picker", "Chat mode", + "Agent Mode Picker", "Agent mode", "Agent", + "Mode" + }; + public const string AgentOptionName = "Agent"; + } + + // ==================== Command ID Constants ==================== + + /// DTE command IDs for opening the Copilot Chat window. + private static readonly string[] OpenChatCommands = + { + "View.GitHub.Copilot.Chat", + "Copilot.Open.Output.Window", + "GitHub.Copilot.Chat.OpenThreads" + }; + + /// DTE command IDs for starting a new chat thread. + private static readonly string[] NewThreadCommands = + { + "GitHub.Copilot.Chat.NewThread", + "GitHub.Copilot.Chat.New", + "GitHub.Copilot.Chat.ClearHistory" + }; + + // ==================== Result Types ==================== + + /// + /// Result of a Copilot integration operation. + /// + public enum OperationResult + { + /// Full automation succeeded — prompt was sent to Copilot. + FullSuccess, + + /// Partial success — Copilot opened but automation may have issues. + PartialSuccess, + + /// Copilot not available — prompt copied to clipboard only. + CopilotNotAvailable, + + /// Operation failed completely. + Failed + } + + /// + /// Detailed result with message for user feedback. + /// + public class IntegrationResult + { + public OperationResult Result { get; } + public string Message { get; } + public Exception Exception { get; } + + private IntegrationResult(OperationResult result, string message, Exception exception = null) + { + Result = result; + Message = message; + Exception = exception; + } + + public bool IsSuccess => + Result == OperationResult.FullSuccess || Result == OperationResult.PartialSuccess; + + public static IntegrationResult FullSuccess(string msg) => + new IntegrationResult(OperationResult.FullSuccess, msg); + + public static IntegrationResult PartialSuccess(string msg) => + new IntegrationResult(OperationResult.PartialSuccess, msg); + + public static IntegrationResult CopilotNotAvailable(string msg) => + new IntegrationResult(OperationResult.CopilotNotAvailable, msg); + + public static IntegrationResult Fail(string msg, Exception ex = null) => + new IntegrationResult(OperationResult.Failed, msg, ex); + } + + // ==================== Public API ==================== + + /// + /// Opens Copilot Chat, starts a new thread, pastes the prompt, and sends it. + /// Returns true if the clipboard was set (even if full automation failed). + /// Maintains backward compatibility with existing callers. + /// + /// The prompt to send to Copilot. + /// Message shown if only clipboard copy succeeded. + public static bool SendPromptToCopilot(string prompt, string clipboardFallbackMessage) + { + IntegrationResult result = SendPromptToCopilotDetailed(prompt, clipboardFallbackMessage); + return result != null && result.Result != OperationResult.Failed; + } + + /// + /// Opens Copilot Chat with prompt and returns detailed result. + /// + /// The prompt to send to Copilot. + /// Message shown if only clipboard copy succeeded. + public static IntegrationResult SendPromptToCopilotDetailed(string prompt, string clipboardFallbackMessage) + { + if (string.IsNullOrWhiteSpace(prompt)) + return IntegrationResult.Fail("Prompt is empty"); + + Log("Starting Copilot integration workflow"); + + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + + // Step 1: Always copy to clipboard first (guaranteed fallback) + if (!CopyToClipboard(prompt)) + { + Log("Failed to copy prompt to clipboard"); + return IntegrationResult.Fail("Failed to copy prompt to clipboard"); + } + Log("Prompt copied to clipboard"); + + // Step 2: Pre-check if Copilot is available (aligned with JetBrains CopilotIntegration.isCopilotAvailable) + if (!IsCopilotAvailable()) + { + Log("Copilot not available (pre-check), prompt copied to clipboard"); + MessageBox.Show( + CxAssistConstants.CopilotOpenInstructionsMessage, + CxAssistConstants.DisplayName, + MessageBoxButton.OK, + MessageBoxImage.Information); + return IntegrationResult.CopilotNotAvailable( + CxAssistConstants.CopilotOpenInstructionsMessage); + } + + // Step 3: Open Copilot Chat + bool opened = TryOpenCopilotChat(); + if (!opened) + { + Log("Copilot Chat failed to open - Copilot may not be installed"); + MessageBox.Show( + CxAssistConstants.CopilotOpenInstructionsMessage, + CxAssistConstants.DisplayName, + MessageBoxButton.OK, + MessageBoxImage.Information); + return IntegrationResult.CopilotNotAvailable( + CxAssistConstants.CopilotOpenInstructionsMessage); + } + + Log("Copilot Chat opened, scheduling automation sequence"); + + // Step 4: Schedule the automation sequence after UI renders + ScheduleAutomatedPromptEntry(prompt); + + return IntegrationResult.PartialSuccess( + "Copilot Chat opened, automation in progress..."); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CopilotIntegration.SendPromptToCopilot"); + try + { + CopyToClipboard(prompt); + MessageBox.Show( + clipboardFallbackMessage ?? CxAssistConstants.CopilotGenericFallbackMessage, + CxAssistConstants.DisplayName, + MessageBoxButton.OK, + MessageBoxImage.Information); + return IntegrationResult.PartialSuccess(clipboardFallbackMessage); + } + catch + { + return IntegrationResult.Fail("Failed to send prompt", ex); + } + } + } + + // ==================== Automation Scheduler ==================== + + /// + /// Schedules the automated prompt entry as a chain of non-blocking + /// DispatcherTimer steps. Each step yields to the UI thread so that + /// Copilot Chat can render and process events between operations. + /// + /// Step 1 (after CopilotOpenDelayMs): Start new thread via DTE. + /// Step 2 (after NewThreadDelayMs): Switch to Agent mode via UI Automation. + /// Step 3 (after AgentModeDelayMs): Re-focus Copilot Chat, paste prompt, submit. + /// + private static void ScheduleAutomatedPromptEntry(string prompt) + { + // New flow: after Copilot opens, require the user to have Agent mode active. + // If Agent mode is not active, show an informational popup and do not + // attempt to switch modes automatically. The prompt is already copied + // to the clipboard as a guaranteed fallback. + ScheduleOnIdle(Timing.CopilotOpenDelayMs, () => + { + try + { + var vsProcess = Process.GetCurrentProcess(); + AutomationElement vsWindow = AutomationElement.FromHandle(vsProcess.MainWindowHandle); + + bool agentActive = false; + if (vsWindow != null) + { + agentActive = IsAgentModeAlreadyActive(vsWindow); + } + + if (!agentActive) + { + Log("Agent mode not active - prompting user to enable Agent mode and use clipboard fallback"); + MessageBox.Show( + "Please select 'Agent' mode in GitHub Copilot Chat. The prompt has been copied to your clipboard — open Copilot Chat, select Agent mode, then paste the prompt to continue.", + CxAssistConstants.DisplayName, + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + // Agent mode is active — proceed to start a new thread and paste/submit + ScheduleOnIdle(Timing.NewThreadDelayMs, () => + { + bool newThreadStarted = TryStartNewThread(); + Log(newThreadStarted + ? "New thread started via DTE command" + : "DTE new-thread commands not available, continuing with current thread"); + + // If a new thread was started, try focusing the Copilot input + if (newThreadStarted) + { + try + { + var vsProc = Process.GetCurrentProcess(); + AutomationElement wnd = AutomationElement.FromHandle(vsProc.MainWindowHandle); + if (wnd != null) + { + bool focused = FocusCopilotInput(wnd); + Log("UI Automation: Focused Copilot input after new thread: " + focused); + } + } + catch (Exception exFocus) + { + Log("UI Automation: error focusing input after new thread: " + exFocus.Message); + } + } + + int agentDelay = newThreadStarted ? Timing.NewThreadDelayMs : Timing.PasteDelayMs; + + // Paste + submit + ScheduleOnIdle(agentDelay, () => + { + PerformPasteAndSubmit(); + }); + }); + } + catch (Exception ex) + { + Log("ScheduleAutomatedPromptEntry error: " + ex.Message); + } + }); + } + + /// + /// Re-focuses Copilot Chat and pastes the prompt from clipboard. + /// Uses only DTE commands and SendKeys — no Thread.Sleep, no blocking + /// UI Automation tree scans. + /// + private static void PerformPasteAndSubmit() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + // Re-focus the Copilot Chat window so SendKeys goes to the right place + TryExecuteDteCommands(OpenChatCommands); + Log("Re-focused Copilot Chat before paste"); + + PasteAndSubmitViaSendKeys(); + + Log("Paste + submit completed"); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CopilotIntegration.PerformPasteAndSubmit"); + MessageBox.Show( + CxAssistConstants.CopilotPasteFailedMessage, + CxAssistConstants.DisplayName, + MessageBoxButton.OK, + MessageBoxImage.Information); + } + } + + /// + /// Schedules an action on the UI thread after a delay, without + /// blocking (no Thread.Sleep). Uses DispatcherTimer at ApplicationIdle + /// so VS remains responsive. + /// + private static void ScheduleOnIdle(int delayMs, Action action) + { + var timer = new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(delayMs) + }; + timer.Tick += (s, e) => + { + timer.Stop(); + try + { + action(); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CopilotIntegration.ScheduleOnIdle"); + MessageBox.Show( + CxAssistConstants.CopilotPasteFailedMessage, + CxAssistConstants.DisplayName, + MessageBoxButton.OK, + MessageBoxImage.Information); + } + }; + timer.Start(); + } + + // ==================== SendKeys ==================== + + /// + /// Pastes the prompt from clipboard and submits via SendKeys. + /// Brief pause between paste and Enter lets VS process the clipboard content. + /// + private static void PasteAndSubmitViaSendKeys() + { + System.Windows.Forms.SendKeys.SendWait("^v"); + System.Threading.Thread.Sleep(Timing.PasteSettleMs); + System.Windows.Forms.SendKeys.SendWait("{ENTER}"); + } + + // ==================== Opening Copilot Chat ==================== + + /// + /// Attempts to open GitHub Copilot Chat using multiple strategies: + /// + /// DTE ExecuteCommand with known command IDs + /// Keyboard shortcut simulation (Ctrl+\ then C) + /// + /// + private static bool TryOpenCopilotChat() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + // Strategy 1: DTE commands (most reliable) + if (TryExecuteDteCommands(OpenChatCommands)) + { + Log("Opened Copilot Chat via DTE command"); + return true; + } + + // Strategy 2: Keyboard shortcut (Ctrl+\ then C) + try + { + System.Windows.Forms.SendKeys.SendWait("^\\c"); + Log("Opened Copilot Chat via keyboard shortcut Ctrl+\\, C"); + return true; + } + catch (Exception ex) + { + Log("Keyboard shortcut failed: " + ex.Message); + } + + return false; + } + + /// + /// Attempts to start a new chat thread via DTE commands. + /// + private static bool TryStartNewThread() + { + ThreadHelper.ThrowIfNotOnUIThread(); + return TryExecuteDteCommands(NewThreadCommands); + } + + // ==================== Availability Check ==================== + + /// + /// Checks if GitHub Copilot is available before attempting to open it. + /// Aligned with JetBrains CopilotIntegration.isCopilotAvailable: checks known + /// command IDs via DTE.Commands to see if any are registered. + /// + public static bool IsCopilotAvailable() + { + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + var dte = GetDte(); + if (dte?.Commands == null) return false; + + foreach (string cmdId in OpenChatCommands) + { + try + { + var cmd = dte.Commands.Item(cmdId); + if (cmd != null) return true; + } + catch + { + } + } + } + catch + { + } + return false; + } + + // ==================== DTE Helpers ==================== + + private static DTE2 GetDte() + { + ThreadHelper.ThrowIfNotOnUIThread(); + return Package.GetGlobalService(typeof(DTE)) as DTE2; + } + + /// + /// Tries each command ID in order. Returns true on the first success. + /// + private static bool TryExecuteDteCommands(string[] commandIds) + { + ThreadHelper.ThrowIfNotOnUIThread(); + var dte = GetDte(); + if (dte == null) return false; + + foreach (string cmd in commandIds) + { + try + { + dte.ExecuteCommand(cmd); + Log("DTE command succeeded: " + cmd); + return true; + } + catch + { + Log("DTE command not available: " + cmd); + } + } + return false; + } + + /// + /// Returns the Visual Studio major version number (e.g. 17 for VS2022). + /// Returns -1 if the version cannot be determined. + /// + private static int GetVisualStudioMajorVersion() + { + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + var dte = GetDte(); + if (dte?.Version != null) + { + var parts = dte.Version.Split('.'); + if (parts.Length > 0 && int.TryParse(parts[0], out int major)) + return major; + } + } + catch { } + return -1; + } + + // ==================== Agent Mode Switching ==================== + + /// + /// Switches Copilot Chat to Agent mode using direct UI Automation (no keyboard navigation). + /// Searches the entire VS main window for the mode picker button by known names, + /// then opens the dropdown and selects Agent. + /// + /// If Agent mode is already active, returns true without attempting to switch. + /// + private static bool TrySwitchToAgentMode() + { + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + Log("Switching to Agent mode via UI Automation..."); + + var vsProcess = Process.GetCurrentProcess(); + AutomationElement vsWindow = AutomationElement.FromHandle(vsProcess.MainWindowHandle); + if (vsWindow == null) + { + Log("UI Automation: Could not get VS main window"); + return false; + } + + if (IsAgentModeAlreadyActive(vsWindow)) + { + Log("UI Automation: Agent mode is already active, skipping switch"); + return true; + } + + AutomationElement modePicker = FindModePickerButton(vsWindow); + if (modePicker == null) + { + Log("UI Automation: Mode Picker button not found"); + ListAvailableElements(vsWindow); + return false; + } + + Log("UI Automation: Found Mode Picker, attempting to open dropdown..."); + + if (modePicker.TryGetCurrentPattern(InvokePattern.Pattern, out object pattern)) + { + Log("UI Automation: Using InvokePattern to open dropdown"); + ((InvokePattern)pattern).Invoke(); + System.Threading.Thread.Sleep(700); + } + else + { + Log("UI Automation: InvokePattern not supported, using mouse click"); + if (!ClickElement(modePicker)) + { + Log("UI Automation: Failed to click Mode Picker button"); + return false; + } + System.Threading.Thread.Sleep(700); + } + + if (SelectAgentDirectly(vsWindow)) + { + Log("UI Automation: Agent mode selected successfully"); + return true; + } + + Log("UI Automation: Failed to select Agent option"); + System.Windows.Forms.SendKeys.SendWait("{ESC}"); + return false; + } + catch (Exception ex) + { + Log("UI Automation error: " + ex.Message); + return false; + } + } + + /// + /// Finds the Mode Picker button by searching the VS window for known names. + /// + private static AutomationElement FindModePickerButton(AutomationElement root) + { + foreach (string pickerName in AutomationProperties.ModePickerNames) + { + var picker = root.FindFirst(TreeScope.Descendants, + new PropertyCondition(AutomationElement.NameProperty, pickerName)); + + if (picker != null) + { + Log("UI Automation: Found Mode Picker button: '" + pickerName + "'"); + return picker; + } + } + return null; + } + + /// + /// Attempts to read the currently selected mode string from the Mode Picker. + /// Tries Value/Text/Selection/SelectionItem patterns then falls back to + /// local descendants, parent siblings, and a nearby spatial search. + /// Returns null when no candidate is found. + /// + private static string GetSelectedMode(AutomationElement modePicker, AutomationElement root) + { + try + { + if (modePicker == null) return null; + + // 1) ValuePattern + try + { + if (modePicker.TryGetCurrentPattern(ValuePattern.Pattern, out object valObj)) + { + var vp = (ValuePattern)valObj; + string v = vp.Current.Value?.Trim(); + if (!string.IsNullOrEmpty(v)) return v; + } + } + catch { } + + // 2) TextPattern + try + { + if (modePicker.TryGetCurrentPattern(TextPattern.Pattern, out object textObj)) + { + var tp = (TextPattern)textObj; + string t = tp.DocumentRange.GetText(-1)?.Trim(); + if (!string.IsNullOrEmpty(t)) return t; + } + } + catch { } + + // 3) SelectionPattern + try + { + if (modePicker.TryGetCurrentPattern(SelectionPattern.Pattern, out object selObj)) + { + var sp = (SelectionPattern)selObj; + var sel = sp.Current.GetSelection(); + if (sel != null && sel.Length > 0) + { + string nm = sel[0].Current.Name?.Trim(); + if (!string.IsNullOrEmpty(nm)) return nm; + } + } + } + catch { } + + // 4) SelectionItem on descendants (some tree items report selection) + try + { + var all = modePicker.FindAll(TreeScope.Descendants, System.Windows.Automation.Condition.TrueCondition); + for (int i = 0; i < all.Count; i++) + { + try + { + var el = all[i]; + if (el.TryGetCurrentPattern(SelectionItemPattern.Pattern, out object sipObj)) + { + var sip = (SelectionItemPattern)sipObj; + if (sip.Current.IsSelected) + { + string nm = el.Current.Name?.Trim(); + if (!string.IsNullOrEmpty(nm)) return nm; + } + } + } + catch { } + } + } + catch { } + + // 5) Fallback: first non-empty named descendant + try + { + var all = modePicker.FindAll(TreeScope.Descendants, System.Windows.Automation.Condition.TrueCondition); + for (int i = 0; i < all.Count; i++) + { + try + { + var el = all[i]; + string nm = el.Current.Name?.Trim(); + if (!string.IsNullOrEmpty(nm)) return nm; + } + catch { } + } + } + catch { } + + // 6) Parent siblings + try + { + var parent = System.Windows.Automation.TreeWalker.ControlViewWalker.GetParent(modePicker); + if (parent != null) + { + var siblings = parent.FindAll(TreeScope.Children, System.Windows.Automation.Condition.TrueCondition); + for (int i = 0; i < siblings.Count; i++) + { + try + { + var s = siblings[i]; + string sn = s.Current.Name?.Trim(); + if (!string.IsNullOrEmpty(sn)) return sn; + } + catch { } + } + } + } + catch { } + + // 7) Spatial fallback: nearby elements overlapping the picker's bounds + try + { + var pickerRect = modePicker.Current.BoundingRectangle; + if (!pickerRect.IsEmpty) + { + var all = root.FindAll(TreeScope.Descendants, System.Windows.Automation.Condition.TrueCondition); + for (int i = 0; i < all.Count; i++) + { + try + { + var el = all[i]; + var r = el.Current.BoundingRectangle; + if (r.IsEmpty) continue; + bool intersect = !(r.Right < pickerRect.Left || r.Left > pickerRect.Right || r.Bottom < pickerRect.Top || r.Top > pickerRect.Bottom); + if (intersect) + { + string nm = el.Current.Name?.Trim(); + if (!string.IsNullOrEmpty(nm)) return nm; + } + } + catch { } + } + } + } + catch { } + + return null; + } + catch + { + return null; + } + } + + /// + /// Checks if Agent mode is already active by examining the Mode Picker + /// button's current display name. If it contains "Agent", the mode is active. + /// + private static bool IsAgentModeAlreadyActive(AutomationElement root) + { + try + { + AutomationElement modePicker = FindModePickerButton(root); + if (modePicker == null) + return false; + + string modePickerName = modePicker.Current.Name ?? ""; + + bool isAgentActive = modePickerName.IndexOf("agent", StringComparison.OrdinalIgnoreCase) >= 0; + + // Prefer a direct read of the selected value via common patterns + string selected = GetSelectedMode(modePicker, root); + if (!string.IsNullOrEmpty(selected)) + { + isAgentActive = selected.IndexOf("agent", StringComparison.OrdinalIgnoreCase) >= 0; + } + else + { + // Fall back to checking the Mode Picker's own Name text + isAgentActive = modePickerName.IndexOf("agent", StringComparison.OrdinalIgnoreCase) >= 0; + } + + // Additional heuristics for newer VS versions (e.g., VS2026): + // 1) Spatial: sometimes the visible selected text is rendered in a + // nearby descendant rather than as the Mode Picker's Name/value. + // 2) VS-version-specific: for VS2026 UI changes, expand the spatial + // search area and allow matches that are near the picker even if + // their bounding rects don't strictly intersect. + if (!isAgentActive) + { + try + { + var pickerRect = modePicker.Current.BoundingRectangle; + var all = root.FindAll(TreeScope.Descendants, System.Windows.Automation.Condition.TrueCondition); + + int vsMajor = GetVisualStudioMajorVersion(); + bool isNewVs = vsMajor >= 19; // treat 19+ as VS2026 or newer + + for (int i = 0; i < all.Count; i++) + { + try + { + var el = all[i]; + string name = el.Current.Name ?? ""; + if (string.IsNullOrEmpty(name)) continue; + + if (name.IndexOf("agent", StringComparison.OrdinalIgnoreCase) < 0) continue; + if (name.IndexOf("search agents", StringComparison.OrdinalIgnoreCase) >= 0) continue; + + var r = el.Current.BoundingRectangle; + if (r.IsEmpty) continue; + + bool intersect = false; + if (!pickerRect.IsEmpty) + { + // For newer VS, expand the picker area by 40 pixels to be more forgiving + var expanded = new System.Windows.Rect( + pickerRect.X - (isNewVs ? 40 : 0), + pickerRect.Y - (isNewVs ? 20 : 0), + pickerRect.Width + (isNewVs ? 80 : 0), + pickerRect.Height + (isNewVs ? 40 : 0)); + + intersect = !(r.Right < expanded.Left || r.Left > expanded.Right || r.Bottom < expanded.Top || r.Top > expanded.Bottom); + } + + if (intersect || !isNewVs) + { + Log("UI Automation: Heuristic detected Agent text: '" + name + "'"); + if (!el.Current.IsOffscreen) + { + isAgentActive = true; + break; + } + } + } + catch { } + } + } + catch { } + } + + // If VS2026 (or newer) couldn't be positively detected, log the VS major version + int detectedVsMajor = GetVisualStudioMajorVersion(); + if (detectedVsMajor >= 0) + { + Log("UI Automation: Detected Visual Studio major version: " + detectedVsMajor); + } + + Log("UI Automation: Mode Picker current name: '" + modePickerName + + "' (Agent active: " + isAgentActive + ")"); + return isAgentActive; + } + catch (Exception ex) + { + Log("UI Automation error in IsAgentModeAlreadyActive: " + ex.Message); + return false; + } + } + + /// + /// Selects Agent by typing "Agent" to filter the dropdown, then clicking + /// the result. Falls back to arrow key navigation if typing doesn't work. + /// + private static bool SelectAgentDirectly(AutomationElement root) + { + try + { + Log("UI Automation: Typing 'Agent' to search/filter dropdown..."); + System.Windows.Forms.SendKeys.SendWait("Agent"); + System.Threading.Thread.Sleep(800); + + Log("UI Automation: Searching for Agent option after typing..."); + AutomationElement agentElement = FindAgentElement(root); + + if (agentElement != null) + { + Log("UI Automation: Found Agent element, clicking it..."); + + if (ClickElement(agentElement)) + { + Log("UI Automation: Agent selected via click"); + System.Threading.Thread.Sleep(400); + return true; + } + + if (agentElement.TryGetCurrentPattern(InvokePattern.Pattern, out object invoke)) + { + ((InvokePattern)invoke).Invoke(); + Log("UI Automation: Agent selected via InvokePattern"); + System.Threading.Thread.Sleep(400); + return true; + } + + if (agentElement.TryGetCurrentPattern(SelectionItemPattern.Pattern, out object selectPattern)) + { + ((SelectionItemPattern)selectPattern).Select(); + Log("UI Automation: Agent selected via SelectionItemPattern"); + System.Threading.Thread.Sleep(400); + return true; + } + } + + Log("UI Automation: Agent element not found after typing, trying arrow key navigation..."); + return SelectAgentViaArrowKeys(); + } + catch (Exception ex) + { + Log("UI Automation error in SelectAgentDirectly: " + ex.Message); + return false; + } + } + + /// + /// Selects Agent mode by pressing Down arrow repeatedly to navigate + /// through dropdown options, then pressing Enter to confirm. + /// Fallback when typing doesn't filter the dropdown. + /// + private static bool SelectAgentViaArrowKeys() + { + try + { + Log("UI Automation: Attempting arrow key navigation..."); + + for (int i = 0; i < 5; i++) + { + System.Windows.Forms.SendKeys.SendWait("{DOWN}"); + System.Threading.Thread.Sleep(200); + + var vsProcess = Process.GetCurrentProcess(); + AutomationElement vsWindow = AutomationElement.FromHandle(vsProcess.MainWindowHandle); + if (vsWindow != null && IsAgentModeAlreadyActive(vsWindow)) + { + Log("UI Automation: Agent mode now active (after " + (i + 1) + " Down presses)"); + System.Windows.Forms.SendKeys.SendWait("{ENTER}"); + System.Threading.Thread.Sleep(400); + return true; + } + } + + Log("UI Automation: Arrow key navigation did not activate Agent mode"); + System.Windows.Forms.SendKeys.SendWait("{ESC}"); + return false; + } + catch (Exception ex) + { + Log("UI Automation error in SelectAgentViaArrowKeys: " + ex.Message); + return false; + } + } + + /// + /// Searches for the "Agent" mode option (MenuItem or ListItem with name "Agent"). + /// Excludes items like "Search agents" that contain "agent" but aren't the mode option. + /// + private static AutomationElement FindAgentElement(AutomationElement root) + { + try + { + var allElements = root.FindAll(TreeScope.Descendants, + System.Windows.Automation.Condition.TrueCondition); + + foreach (AutomationElement el in allElements) + { + try + { + string name = el.Current.Name ?? ""; + string nameLower = name.ToLowerInvariant(); + + if (nameLower == "agent" || nameLower.StartsWith("agent ")) + { + string ctType = el.Current.ControlType.ProgrammaticName ?? ""; + + if (ctType.Contains("MenuItem") || ctType.Contains("ListItem")) + { + Log("UI Automation: Found Agent mode option [" + ctType + "]: '" + name + "'"); + return el; + } + } + } + catch + { + } + } + + foreach (AutomationElement el in allElements) + { + try + { + string name = el.Current.Name ?? ""; + if (string.Equals(name, "agent", StringComparison.OrdinalIgnoreCase)) + { + Log("UI Automation: Found Agent element (second pass): '" + name + "'"); + return el; + } + } + catch + { + } + } + } + catch (Exception ex) + { + Log("UI Automation error in FindAgentElement: " + ex.Message); + } + + return null; + } + + /// + /// Clicks an element using mouse coordinates from its bounding rectangle. + /// + private static bool ClickElement(AutomationElement element) + { + try + { + var rect = element.Current.BoundingRectangle; + if (rect.IsEmpty || rect.Width == 0 || rect.Height == 0) + { + Log("UI Automation: Element has no valid bounding rectangle"); + return false; + } + + int clickX = (int)(rect.X + rect.Width / 2); + int clickY = (int)(rect.Y + rect.Height / 2); + + SetCursorPos(clickX, clickY); + System.Threading.Thread.Sleep(100); + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, IntPtr.Zero); + System.Threading.Thread.Sleep(50); + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, IntPtr.Zero); + + Log("UI Automation: Clicked element at (" + clickX + ", " + clickY + ")"); + return true; + } + catch (Exception ex) + { + Log("UI Automation: Mouse click failed: " + ex.Message); + return false; + } + } + + /// + /// Logs elements whose names contain mode-related keywords for debugging. + /// + private static void ListAvailableElements(AutomationElement root) + { + try + { + var allElements = root.FindAll(TreeScope.Descendants, + System.Windows.Automation.Condition.TrueCondition); + + int count = 0; + foreach (AutomationElement el in allElements) + { + try + { + string name = el.Current.Name ?? ""; + string ctType = el.Current.ControlType.ProgrammaticName ?? ""; + string nameLower = name.ToLowerInvariant(); + + if (!string.IsNullOrEmpty(name) && (nameLower.Contains("mode") || + nameLower.Contains("ask") || nameLower.Contains("edit") || + nameLower.Contains("debug") || nameLower.Contains("agent"))) + { + Log("UI Automation: Available element [" + ctType + "]: '" + name + "'"); + count++; + } + } + catch + { + } + } + + Log("UI Automation: Total matching elements found: " + count); + } + catch (Exception ex) + { + Log("UI Automation error in ListAvailableElements: " + ex.Message); + } + } + + /// + /// Attempts to find the Copilot Chat text input area and set keyboard focus to it. + /// Uses heuristics: editable controls, document controls, or any focusable + /// element whose name looks like a chat prompt. Returns true when focus set. + /// + private static bool FocusCopilotInput(AutomationElement root) + { + try + { + if (root == null) return false; + + var all = root.FindAll(TreeScope.Descendants, System.Windows.Automation.Condition.TrueCondition); + for (int i = 0; i < all.Count; i++) + { + try + { + var el = all[i]; + if (!el.Current.IsEnabled) continue; + + string ct = el.Current.ControlType?.ProgrammaticName ?? ""; + string name = el.Current.Name ?? ""; + + bool likelyEdit = ct.IndexOf("Edit", StringComparison.OrdinalIgnoreCase) >= 0 + || ct.IndexOf("Document", StringComparison.OrdinalIgnoreCase) >= 0; + + bool nameHint = !string.IsNullOrEmpty(name) && ( + name.IndexOf("type", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("message", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("chat", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("prompt", StringComparison.OrdinalIgnoreCase) >= 0); + + if ((likelyEdit || nameHint) && el.Current.IsKeyboardFocusable) + { + try + { + el.SetFocus(); + System.Threading.Thread.Sleep(120); + return true; + } + catch { } + } + } + catch { } + } + + // Final fallback: any focusable element + for (int i = 0; i < all.Count; i++) + { + try + { + var el = all[i]; + if (el.Current.IsKeyboardFocusable && el.Current.IsEnabled) + { + try + { + el.SetFocus(); + System.Threading.Thread.Sleep(120); + return true; + } + catch { } + } + } + catch { } + } + } + catch (Exception ex) + { + Log("UI Automation: FocusCopilotInput error: " + ex.Message); + } + return false; + } + + // ==================== Native Mouse Click ==================== + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern bool SetCursorPos(int X, int Y); + + [System.Runtime.InteropServices.DllImport("user32.dll")] + private static extern void mouse_event(uint dwFlags, int dx, int dy, uint dwData, IntPtr dwExtraInfo); + + private const uint MOUSEEVENTF_LEFTDOWN = 0x02; + private const uint MOUSEEVENTF_LEFTUP = 0x04; + + // ==================== Clipboard ==================== + + private static bool CopyToClipboard(string text) + { + try + { + Clipboard.SetText(text); + return true; + } + catch (Exception ex) + { + Log("Clipboard failed: " + ex.Message); + return false; + } + } + + // ==================== Logging ==================== + + private static void Log(string message) + { + Debug.WriteLine("[" + CxAssistConstants.LogCategory + "] CopilotIntegration: " + message); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistConstants.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistConstants.cs new file mode 100644 index 00000000..f4445fda --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistConstants.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Shared constants for CxAssist (display names, log categories, theme and resource names). + /// Use these instead of magic strings to maintain consistency and simplify changes. + /// + internal static class CxAssistConstants + { + #region Scanner Enable/Disable (aligned with JetBrains GlobalScannerController) + + private static readonly HashSet _disabledScanners = new HashSet(); + private static readonly object _scannerLock = new object(); + + /// + /// Whether the given scanner type is enabled (aligned with JetBrains GlobalScannerController.isScannerGloballyEnabled). + /// All scanners are enabled by default. + /// + public static bool IsScannerEnabled(ScannerType scanner) + { + lock (_scannerLock) + { + return !_disabledScanners.Contains(scanner); + } + } + + /// Enables or disables a scanner globally. Disabled scanner findings are excluded from decoration. + public static void SetScannerEnabled(ScannerType scanner, bool enabled) + { + lock (_scannerLock) + { + if (enabled) + _disabledScanners.Remove(scanner); + else + _disabledScanners.Add(scanner); + } + CxAssistOutputPane.WriteToOutputPane(string.Format(SCANNER_CONFIG_CHANGED, scanner, enabled ? "enabled" : "disabled")); + } + + /// Whether any scanner is currently enabled. + public static bool IsAnyScannerEnabled() + { + lock (_scannerLock) + { + return _disabledScanners.Count < Enum.GetValues(typeof(ScannerType)).Length; + } + } + + #endregion + + #region AI Agent File Skip (aligned with JetBrains DevAssistInspection.isAgentEvent) + + private static readonly string[] AiAgentFileNames = { "Dummy.txt", "AIAssistantInput" }; + private static readonly string[] AiAgentFilePrefixes = { "/AIAssistantInput" }; + + /// + /// Whether the file path belongs to a Copilot/AI assistant temporary file that should be skipped. + /// JetBrains skips Dummy.txt and AIAssistantInput-* files generated during remediation or chat prompts. + /// + public static bool IsAIAgentFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + string fileName = System.IO.Path.GetFileName(filePath); + if (string.IsNullOrEmpty(fileName)) return false; + + foreach (var agentName in AiAgentFileNames) + { + if (fileName.Equals(agentName, StringComparison.OrdinalIgnoreCase)) + return true; + } + foreach (var prefix in AiAgentFilePrefixes) + { + string prefixName = prefix.TrimStart('/'); + if (fileName.StartsWith(prefixName, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + #endregion + + /// Vulnerability.LineNumber is 1-based in the model. Convert to 0-based for editor/taggers (ITextSnapshot). + public static int To0BasedLineForEditor(ScannerType scanner, int lineNumber) + { + return Math.Max(0, lineNumber - 1); + } + + /// Convert to 1-based line for DTE MoveToLineAndOffset. Vulnerability.LineNumber is already 1-based. + public static int To1BasedLineForDte(ScannerType scanner, int lineNumber) + { + return Math.Max(1, lineNumber); + } + + /// + /// Whether the severity should be shown as a problem (underline + Error List / Problems view). + /// Aligned with JetBrains DevAssistUtils.isProblem: not OK, not UNKNOWN, not IGNORED. + /// Gutter icons are shown for all severities; underline and problem list only for "problem" severities. + /// + public static bool IsProblem(SeverityLevel severity) + { + return severity != SeverityLevel.Ok + && severity != SeverityLevel.Unknown + && severity != SeverityLevel.Ignored; + } + + /// + /// Whether the 1-based line number is within the document range. + /// Aligned with JetBrains DevAssistUtils.isLineOutOfRange (inverted): valid when lineNumber in [1, lineCount]. + /// + public static bool IsLineInRange(int lineNumber1Based, int documentLineCount) + { + return lineNumber1Based >= 1 && lineNumber1Based <= documentLineCount; + } + + /// Removes "(CVE-...)" and "(Malicious)" from package/title text for display (e.g. "node-ipc (Malicious)@10.1.1" → "node-ipc"). + public static string StripCveFromDisplayName(string text) + { + if (string.IsNullOrEmpty(text)) return text; + text = Regex.Replace(text.Trim(), @"\s*\(CVE-[^)]+\)", "").Trim(); + text = Regex.Replace(text, @"\s*\(Malicious\)", "", RegexOptions.IgnoreCase).Trim(); + return text; + } + + /// Formats secret title for display: kebab-case to Title-Case (e.g. "generic-api-key" → "Generic-Api-Key"). Reference formatTitle. + public static string FormatSecretTitle(string title) + { + if (string.IsNullOrEmpty(title)) return title; + var parts = title.Split(new[] { '-' }, StringSplitOptions.None); + for (int i = 0; i < parts.Length; i++) + { + if (parts[i].Length > 0) + parts[i] = char.ToUpperInvariant(parts[i][0]) + parts[i].Substring(1).ToLowerInvariant(); + } + return string.Join("-", parts); + } + /// Product name shown in UI (Quick Info header, messages, Error List). + public const string DisplayName = "Checkmarx One Assist"; + + /// Suffix for grouped IaC findings on same line (reference-style). Use as: count + MultipleIacIssuesOnLine. + public const string MultipleIacIssuesOnLine = " IAC issues detected on this line"; + + /// Suffix for grouped ASCA findings on same line (reference-style). Use as: count + MultipleAscaViolationsOnLine. + public const string MultipleAscaViolationsOnLine = " ASCA violations detected on this line"; + + /// Suffix for grouped OSS findings on same line. Use as: count + MultipleOssIssuesOnLine. + public const string MultipleOssIssuesOnLine = " OSS issues detected on this line"; + + /// Suffix for grouped Secrets findings on same line. Use as: count + MultipleSecretsIssuesOnLine. + public const string MultipleSecretsIssuesOnLine = " Secrets issues detected on this line"; + + /// Suffix for grouped Containers findings on same line. Use as: count + MultipleContainersIssuesOnLine. + public const string MultipleContainersIssuesOnLine = " Container issues detected on this line"; + + /// Human-readable severity name for UI (Quick Info, tooltips, etc.). + public static string GetRichSeverityName(SeverityLevel severity) + { + switch (severity) + { + case SeverityLevel.Critical: return "Critical"; + case SeverityLevel.High: return "High"; + case SeverityLevel.Medium: return "Medium"; + case SeverityLevel.Low: return "Low"; + case SeverityLevel.Info: return "Info"; + case SeverityLevel.Malicious: return "Malicious"; + case SeverityLevel.Unknown: return "Unknown"; + case SeverityLevel.Ok: return "Ok"; + case SeverityLevel.Ignored: return "Ignored"; + default: return severity.ToString(); + } + } + + /// Log category for debug/trace output (e.g. Debug.WriteLine). + public const string LogCategory = "CxAssist"; + + #region Output Pane Messages (main lifecycle messages written to VS Output Window) + + public const string UI_DECORATED_SUCCESSFULLY = "UI decorated successfully on file open for file: {0} ({1} findings)"; + public const string NO_SCANNER_ENABLED_SKIPPING = "No scanner is enabled, skipping restoring gutter icons for file: {0}"; + public const string AI_AGENT_FILE_SKIPPING = "Received copilot/AI agent event for file: {0}. Skipping file."; + public const string SCANNER_CONFIG_CHANGED = "Scanner config changed: {0} is now {1}"; + public const string DECORATING_UI_FOR_FILE = "Decorating UI using {0} results for file: {1}"; + public const string NO_VULNERABILITIES_FOR_FILE = "No vulnerabilities found in scan result for file: {0}"; + public const string FINDINGS_WINDOW_INITIATED = "Checkmarx One Assist Findings window initiated"; + public const string ICONS_LOADED_FOR_THEME = "Loaded icons for theme: {0}"; + public const string ICONS_RELOADING_FOR_THEME = "Icons reloading for theme change ({0} -> {1})"; + public const string REMEDIATION_CALLED = "Remediation called: {0} for issue: {1}"; + public const string REMEDIATION_STARTED = "{0} remediation started for issue: {1}, for file: {2}"; + public const string REMEDIATION_SENT_COPILOT = "{0} remediation sent to Copilot for issue: {1}, for file: {2}"; + public const string REMEDIATION_COMPLETED_CLIPBOARD = "{0} remediation completed (clipboard) for issue: {1}, for file: {2}"; + public const string VIEW_DETAILS_STARTED = "{0} explanation started for issue: {1}, for file: {2}"; + public const string VIEW_DETAILS_SENT_COPILOT = "{0} explanation sent to Copilot for issue: {1}, for file: {2}"; + public const string VIEW_DETAILS_COMPLETED_CLIPBOARD = "{0} explanation completed (clipboard) for issue: {1}, for file: {2}"; + public const string ERROR_LIST_SYNCED = "Error List synced: {0} tasks for {1} files"; + public const string FIX_PROMPT_COPIED = "Fix prompt copied to clipboard for issue: {0}"; + public const string FAILED_COPY_CLIPBOARD = "Failed to copy text to clipboard"; + + #endregion + + /// Theme folder name for dark theme icons. + public const string ThemeDark = "Dark"; + + /// Theme folder name for light theme icons. + public const string ThemeLight = "Light"; + + /// Badge image file name (header in Quick Info). + public const string BadgeIconFileName = "cxone_assist.png"; + + /// + /// When true, CxAssist findings are also added to the built-in Error List. + /// When false, the hover popup shows only one block (our Quick Info). + /// + public const bool SyncFindingsToBuiltInErrorList = true; + + /// + /// When true and SyncFindingsToBuiltInErrorList is true: Error List task Text is set empty so the hover + /// popup does not show a second duplicate block (VS still shows the task in the list with File/Line/Column; + /// full details are in our Quick Info on hover). When false: full description is shown in the Error List + /// but the same text appears again in the hover (duplicate). + /// + /// Menu label (reference-aligned). + public const string FixWithCxOneAssist = "Fix with Checkmarx One Assist"; + public const string ViewDetails = "View details"; + public const string IgnoreThis = "Ignore this vulnerability"; + public const string IgnoreAllOfThisType = "Ignore all of this type"; + public const string CopyMessage = "Copy Message"; + public const string IgnoreFeatureInProgressMessage = "This feature is currently in progress and will be available in a future release."; + public const string SecretFindingLabel = "Secret finding"; + public const string SastVulnerabilityLabel = "SAST vulnerability"; + public const string IacVulnerabilityLabel = "IaC vulnerability"; + /// OSS Quick Info header suffix (reference: "validator@13.12.0 - High Severity Package"). + public const string SeverityPackageLabel = "Severity Package"; + + /// Container image Quick Info header suffix (reference: "nginx:latest - Critical Severity Image"). + public const string SeverityImageLabel = "Severity Image"; + + // --- Copilot / DevAssist (reusable messages for Fix & View details) --- + public const string CopilotFixFallbackMessage = "Fix prompt copied. Paste into GitHub Copilot Chat to get remediation steps."; + public const string CopilotViewDetailsFallbackMessage = "View details prompt copied. Paste into GitHub Copilot Chat to get an explanation."; + public const string CopilotPromptSentMessage = "Prompt was sent to GitHub Copilot Chat. Check the chat for the response."; + public const string CopilotPasteFailedMessage = "Prompt copied to clipboard! Paste it into GitHub Copilot Chat (Agent Mode)."; + public const string CopilotOpenInstructionsMessage = "Prompt copied to clipboard! Paste it into GitHub Copilot Chat (Agent Mode)."; + public const string CopilotGenericFallbackMessage = "Prompt copied to clipboard. Paste into GitHub Copilot Chat."; + + /// Context menu / Error List / Quick Info / Quick Fix: "Ignore this [finding type]" label based on scanner. + public static string GetIgnoreThisLabel(ScannerType scanner) + { + switch (scanner) + { + case ScannerType.Secrets: return "Ignore this secret in file"; + case ScannerType.Containers: + case ScannerType.ASCA: + case ScannerType.IaC: + case ScannerType.OSS: + default: return "Ignore this vulnerability"; + } + } + + /// True only for OSS and Containers; Secret, ASCA, IaC show only "Ignore this" (no "Ignore all"). + public static bool ShouldShowIgnoreAll(ScannerType scanner) + { + return scanner == ScannerType.OSS || scanner == ScannerType.Containers; + } + + /// Context menu / Error List: "Ignore all of this type" for OSS and Containers (only shown for those scanners). + public static string GetIgnoreAllLabel(ScannerType scanner) + { + return "Ignore all of this type"; + } + + /// Success message after "Ignore this" (e.g. "Vulnerability ignored."). + public static string GetIgnoreThisSuccessMessage(ScannerType scanner) + { + switch (scanner) + { + case ScannerType.Secrets: return "Secret ignored."; + case ScannerType.Containers: return "Container image ignored."; + case ScannerType.IaC: return "IaC finding ignored."; + case ScannerType.ASCA: return "ASCA violation ignored."; + case ScannerType.OSS: + default: return "Vulnerability ignored."; + } + } + + /// Success message after "Ignore all" (e.g. "All vulnerabilities of this type ignored."). + public static string GetIgnoreAllSuccessMessage(ScannerType scanner) + { + switch (scanner) + { + case ScannerType.Secrets: return "All secrets ignored."; + case ScannerType.Containers: return "All container issues ignored."; + case ScannerType.IaC: return "All IaC findings ignored."; + case ScannerType.ASCA: return "All ASCA violations ignored."; + case ScannerType.OSS: + default: return "All OSS issues ignored."; + } + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistCopilotActions.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistCopilotActions.cs new file mode 100644 index 00000000..0ddee99b --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistCopilotActions.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Windows; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Prompts; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Reusable DevAssist actions: Fix with Checkmarx One Assist and View details. + /// Builds the appropriate prompt and sends it to GitHub Copilot Chat. Use from Quick Info, Error List, Findings window, and Quick Fix. + /// + internal static class CxAssistCopilotActions + { + /// + /// Sends a "Fix with Checkmarx One Assist" prompt to Copilot for the given vulnerability. + /// Builds a remediation prompt by scanner type and opens Copilot with a new chat. No-op if no prompt is available. + /// For IaC/ASCA, automatically resolves all issues on the same line (aligned with JetBrains IacScanResultAdaptor grouping). + /// + public static void SendFixWithAssist(Vulnerability v, IReadOnlyList sameLineVulns = null) + { + if (v == null) return; + string issueDesc = v.Title ?? v.Description ?? "unknown"; + string filePath = v.FilePath ?? "unknown"; + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.REMEDIATION_CALLED, "Fix", issueDesc)); + + if (sameLineVulns == null && (v.Scanner == Models.ScannerType.IaC || v.Scanner == Models.ScannerType.ASCA)) + { + sameLineVulns = CxAssistDisplayCoordinator.FindAllVulnerabilitiesForLine(v); + } + + string prompt = CxOneAssistFixPrompts.BuildForVulnerability(v, sameLineVulns); + if (string.IsNullOrEmpty(prompt)) + { + ShowNoPromptMessage(v?.Title ?? v?.Description ?? "—", isFix: true); + return; + } + + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.REMEDIATION_STARTED, v.Scanner, issueDesc, filePath)); + bool sent = CopilotIntegration.SendPromptToCopilot(prompt, CxAssistConstants.CopilotFixFallbackMessage); + if (sent) + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.REMEDIATION_SENT_COPILOT, v.Scanner, issueDesc, filePath)); + else + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.REMEDIATION_COMPLETED_CLIPBOARD, v.Scanner, issueDesc, filePath)); + } + + /// + /// Sends a "View details" prompt to Copilot for the given vulnerability. + /// Builds an explanation prompt by scanner type and opens Copilot with a new chat. No-op if no prompt is available. + /// For OSS findings, automatically resolves all CVEs for the same package (aligned with JetBrains ScanIssue.getVulnerabilities()). + /// For IaC/ASCA, automatically resolves all issues on the same line (aligned with JetBrains IacScanResultAdaptor grouping). + /// + /// The vulnerability to explain. + /// Optional. Related vulnerabilities (same package for OSS, same line for IaC/ASCA). If null, auto-resolved from coordinator. + public static void SendViewDetails(Vulnerability v, IReadOnlyList relatedVulns = null) + { + if (v == null) return; + string issueDesc = v.Title ?? v.Description ?? "unknown"; + string filePath = v.FilePath ?? "unknown"; + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.REMEDIATION_CALLED, "View details", issueDesc)); + + if (relatedVulns == null) + { + if (v.Scanner == Models.ScannerType.OSS) + relatedVulns = CxAssistDisplayCoordinator.FindAllVulnerabilitiesForPackage(v); + else if (v.Scanner == Models.ScannerType.IaC || v.Scanner == Models.ScannerType.ASCA) + relatedVulns = CxAssistDisplayCoordinator.FindAllVulnerabilitiesForLine(v); + } + + string prompt = ViewDetailsPrompts.BuildForVulnerability(v, relatedVulns); + if (string.IsNullOrEmpty(prompt)) + { + ShowNoPromptMessage($"{v?.Title ?? ""}\n{v?.Description ?? ""}\nSeverity: {v?.Severity}", isFix: false); + return; + } + + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.VIEW_DETAILS_STARTED, v.Scanner, issueDesc, filePath)); + bool sent = CopilotIntegration.SendPromptToCopilot(prompt, CxAssistConstants.CopilotViewDetailsFallbackMessage); + if (sent) + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.VIEW_DETAILS_SENT_COPILOT, v.Scanner, issueDesc, filePath)); + else + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.VIEW_DETAILS_COMPLETED_CLIPBOARD, v.Scanner, issueDesc, filePath)); + } + + private static void ShowNoPromptMessage(string detail, bool isFix) + { + string message = isFix + ? "No fix prompt available for this finding.\n" + detail + : "View Details:\n" + detail; + MessageBox.Show(message, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistDisplayCoordinator.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistDisplayCoordinator.cs new file mode 100644 index 00000000..467aee6d --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistDisplayCoordinator.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Reflection; +using Microsoft.VisualStudio.Text; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.GutterIcons; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; +using System.Linq; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Single coordinator for CxAssist display (Option B). + /// Takes one List<Vulnerability> and updates gutter, underline, and problem window in one go. + /// Stores issues per file (like reference ProblemHolderService) and notifies via IssuesUpdated so the findings window can subscribe and stay in sync. + /// + public static class CxAssistDisplayCoordinator + { + private static readonly object _lock = new object(); + private static Dictionary> _fileToIssues = new Dictionary>(StringComparer.OrdinalIgnoreCase); + private static bool _themeHandlerRegistered; + + /// + /// Subscribes to AssistIconLoader.ThemeChanged so all open taggers re-render + /// with the new theme icons (aligned with JetBrains createProblemDescriptorsOnThemeChanged). + /// Call once at startup. + /// + public static void EnsureThemeChangeHandler() + { + if (_themeHandlerRegistered) return; + _themeHandlerRegistered = true; + AssistIconLoader.EnsureThemeChangeSubscription(); + AssistIconLoader.ThemeChanged += OnThemeChanged; + } + + private static void OnThemeChanged() + { + IReadOnlyDictionary> snapshot; + lock (_lock) + { + var copy = new Dictionary>(_fileToIssues.Count, StringComparer.OrdinalIgnoreCase); + foreach (var kv in _fileToIssues) + copy[kv.Key] = new List(kv.Value); + snapshot = copy; + } + foreach (var kv in snapshot) + { + var buffer = GutterIcons.CxAssistGlyphTaggerProvider.GetBufferForFile(kv.Key); + if (buffer != null) + UpdateFindings(buffer, kv.Value, kv.Key); + } + IssuesUpdated?.Invoke(snapshot); + } + + /// + /// Normalizes a file path for use as the per-file map key (same file always maps to the same key). + /// + private static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) return path; + try + { + return Path.GetFullPath(path); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "DisplayCoordinator.NormalizePath"); + return path; + } + } + + /// + /// Gets the file path for the buffer when it is backed by a file (e.g. for passing to mock data or scan). + /// Returns null if the buffer has no associated document. + /// + public static string GetFilePathForBuffer(ITextBuffer buffer) => TryGetFilePathFromBuffer(buffer); + + /// + /// Tries to get the file path for the buffer from ITextDocument (when the buffer is backed by a file). + /// Uses reflection so we don't require an extra assembly reference. + /// + private static string TryGetFilePathFromBuffer(ITextBuffer buffer) + { + if (buffer?.Properties == null) return null; + try + { + // ITextDocument is in Microsoft.VisualStudio.Text.Logic (or Text.Data); key is often the type + var docType = Type.GetType("Microsoft.VisualStudio.Text.ITextDocument, Microsoft.VisualStudio.Text.Logic", false) + ?? Type.GetType("Microsoft.VisualStudio.Text.ITextDocument, Microsoft.VisualStudio.Text.Data", false); + if (docType == null) return null; + if (!buffer.Properties.TryGetProperty(docType, out object doc) || doc == null) return null; + var pathProp = docType.GetProperty("FilePath", BindingFlags.Public | BindingFlags.Instance); + return pathProp?.GetValue(doc) as string; + } + catch + { + return null; + } + } + + /// + /// Raised when issues are updated (any file). Subscribers (e.g. findings window) can refresh to stay in sync (reference ISSUE_TOPIC-like). + /// + public static event Action>> IssuesUpdated; + + /// + /// Gets all issues by file path (like reference ProblemHolderService.GetAllIssues). + /// + public static IReadOnlyDictionary> GetAllIssuesByFile() + { + lock (_lock) + { + var copy = new Dictionary>(_fileToIssues.Count, StringComparer.OrdinalIgnoreCase); + foreach (var kv in _fileToIssues) + copy[kv.Key] = new List(kv.Value); + return copy; + } + } + + /// + /// Gets the current findings as a single flattened list (for backward compatibility and for BuildFileNodesFromVulnerabilities). + /// + public static List GetCurrentFindings() + { + lock (_lock) + { + if (_fileToIssues.Count == 0) return null; + var flat = new List(); + foreach (var list in _fileToIssues.Values) + flat.AddRange(list); + return flat; + } + } + + /// + /// Finds a vulnerability by Id in the current findings (e.g. from Error List task HelpKeyword). + /// + public static Vulnerability FindVulnerabilityById(string id) + { + if (string.IsNullOrEmpty(id)) return null; + lock (_lock) + { + foreach (var list in _fileToIssues.Values) + { + var v = list?.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (v != null) return v; + } + return null; + } + } + + /// + /// Finds the first vulnerability at the given location (for Error List selection by document + line). + /// + /// Full path of the file (normalized for comparison). + /// 0-based line number (Error List uses 0-based). + public static Vulnerability FindVulnerabilityByLocation(string documentPath, int zeroBasedLine) + { + if (string.IsNullOrEmpty(documentPath)) return null; + string key; + try { key = Path.GetFullPath(documentPath); } + catch { key = documentPath; } + lock (_lock) + { + if (!_fileToIssues.TryGetValue(key, out var list) || list == null) return null; + // Match by 0-based line: Vulnerability.LineNumber is 1-based, convert for comparison. + return list.FirstOrDefault(v => + CxAssistConstants.To0BasedLineForEditor(v.Scanner, v.LineNumber) == zeroBasedLine); + } + } + + /// + /// Finds all OSS vulnerabilities for the same package (same PackageName + PackageVersion) across all files. + /// Aligned with JetBrains ScanIssue.getVulnerabilities() which returns all CVEs for a package. + /// Returns null for non-OSS scanners or when no additional vulnerabilities exist. + /// + public static List FindAllVulnerabilitiesForPackage(Vulnerability v) + { + if (v == null || v.Scanner != Models.ScannerType.OSS || string.IsNullOrEmpty(v.PackageName)) + return null; + + lock (_lock) + { + var result = new List(); + foreach (var list in _fileToIssues.Values) + { + if (list == null) continue; + foreach (var vuln in list) + { + if (vuln.Scanner == Models.ScannerType.OSS + && string.Equals(vuln.PackageName, v.PackageName, StringComparison.OrdinalIgnoreCase) + && string.Equals(vuln.PackageVersion, v.PackageVersion, StringComparison.OrdinalIgnoreCase)) + { + result.Add(vuln); + } + } + } + return result.Count > 0 ? result : null; + } + } + + /// + /// Finds all vulnerabilities of the same scanner type on the same line in the same file. + /// Used for IaC/ASCA where multiple issues can be grouped on a single line + /// (aligned with JetBrains IacScanResultAdaptor grouping by filePath:line). + /// Returns null when no matching vulnerabilities exist. + /// + public static List FindAllVulnerabilitiesForLine(Vulnerability v) + { + if (v == null || string.IsNullOrEmpty(v.FilePath)) + return null; + + string key; + try { key = Path.GetFullPath(v.FilePath); } + catch { key = v.FilePath; } + + int zeroBasedLine = CxAssistConstants.To0BasedLineForEditor(v.Scanner, v.LineNumber); + + lock (_lock) + { + if (!_fileToIssues.TryGetValue(key, out var list) || list == null) + return null; + + var result = list.Where(vuln => + vuln.Scanner == v.Scanner + && CxAssistConstants.To0BasedLineForEditor(vuln.Scanner, vuln.LineNumber) == zeroBasedLine) + .ToList(); + + return result.Count > 0 ? result : null; + } + } + + /// + /// Updates gutter icons, underlines (squiggles), and stored findings for the problem window in one call. + /// Stores issues per file and raises IssuesUpdated so the findings window can stay in sync (reference-like). + /// + /// Text buffer for the open file (used to get glyph and error taggers). + /// Findings to show; can be null or empty to clear for this file. + /// Optional. File path for per-file storage. If null, uses first vulnerability's FilePath when list is non-empty. + public static void UpdateFindings(ITextBuffer buffer, List vulnerabilities, string filePath = null) + { + if (buffer == null) + return; + + // Filter out findings from disabled scanners (aligned with JetBrains DevAssistFileListener.getScanIssuesForEnabledScanner) + var list = vulnerabilities != null + ? vulnerabilities.FindAll(v => CxAssistConstants.IsScannerEnabled(v.Scanner)) + : new List(); + + if (vulnerabilities == null || vulnerabilities.Count == 0) + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.NO_VULNERABILITIES_FOR_FILE, filePath ?? "unknown")); + + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.DECORATING_UI_FOR_FILE, list.Count, filePath ?? "unknown")); + + // 1. Update gutter + var glyphTagger = CxAssistErrorHandler.TryGet(() => CxAssistGlyphTaggerProvider.GetTaggerForBuffer(buffer), "Coordinator.GetGlyphTagger", null); + if (glyphTagger != null) + CxAssistErrorHandler.TryRun(() => glyphTagger.UpdateVulnerabilities(list), "Coordinator.GlyphTagger.UpdateVulnerabilities"); + + // 2. Update underline + var errorTagger = CxAssistErrorHandler.TryGet(() => CxAssistErrorTaggerProvider.GetTaggerForBuffer(buffer), "Coordinator.GetErrorTagger", null); + if (errorTagger != null) + CxAssistErrorHandler.TryRun(() => errorTagger.UpdateVulnerabilities(list), "Coordinator.ErrorTagger.UpdateVulnerabilities"); + + // 3. Store per file and notify (reference ProblemHolderService + ISSUE_TOPIC-like) + CxAssistErrorHandler.TryRun(() => + { + // Prefer explicit filePath, then path from buffer (so we can clear when list is empty), then first vulnerability + string resolvedPath = filePath ?? TryGetFilePathFromBuffer(buffer) ?? (list.Count > 0 ? list[0].FilePath : null); + string key = NormalizePath(resolvedPath); + if (string.IsNullOrEmpty(key)) return; + + IReadOnlyDictionary> snapshot; + lock (_lock) + { + if (list.Count == 0) + _fileToIssues.Remove(key); + else + _fileToIssues[key] = new List(list); + var copy = new Dictionary>(_fileToIssues.Count, StringComparer.OrdinalIgnoreCase); + foreach (var kv in _fileToIssues) + copy[kv.Key] = new List(kv.Value); + snapshot = copy; + } + IssuesUpdated?.Invoke(snapshot); + }, "Coordinator.StoreCurrentFindings"); + + } + + /// + /// Sets the stored findings by file and raises IssuesUpdated without updating gutter/underline. + /// Use when displaying fallback data (e.g. package.json mock) in the Findings window so the Error List shows the same data. + /// + public static void SetFindingsByFile(IReadOnlyDictionary> issuesByFile) + { + if (issuesByFile == null) return; + + IReadOnlyDictionary> snapshot; + lock (_lock) + { + _fileToIssues.Clear(); + foreach (var kv in issuesByFile) + { + if (string.IsNullOrEmpty(kv.Key) || kv.Value == null) continue; + string key = NormalizePath(kv.Key); + if (string.IsNullOrEmpty(key)) continue; + _fileToIssues[key] = new List(kv.Value); + } + var copy = new Dictionary>(_fileToIssues.Count, StringComparer.OrdinalIgnoreCase); + foreach (var kv in _fileToIssues) + copy[kv.Key] = new List(kv.Value); + snapshot = copy; + } + IssuesUpdated?.Invoke(snapshot); + } + + /// + /// Returns the cached vulnerabilities for the given file path, or null if none exist. + /// Used to restore gutter icons and underlines when a file is reopened (JetBrains: DevAssistFileListener.restoreGutterIcons). + /// + public static List GetCachedVulnerabilitiesForFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return null; + string key = NormalizePath(filePath); + if (string.IsNullOrEmpty(key)) return null; + lock (_lock) + { + if (_fileToIssues.TryGetValue(key, out var list) && list != null && list.Count > 0) + return new List(list); + return null; + } + } + + /// + /// Updates the problem window control with the current findings (builds FileNodes and calls SetAllFileNodes). + /// Call this when the Findings window is shown so it displays the same data as gutter/underline. + /// + /// The CxAssist Findings control to update. + /// Optional; if null, severity icons are not set. + /// Optional; callback (filePath -> ImageSource) for file-type icon per file. If null, file icon is not set. + public static void RefreshProblemWindow( + CxAssistFindingsControl findingsControl, + Func loadSeverityIcon = null, + Func loadFileIcon = null) + { + if (findingsControl == null) return; + + CxAssistErrorHandler.TryRun(() => + { + List current = GetCurrentFindings(); + ObservableCollection fileNodes = current != null && current.Count > 0 + ? FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(current, loadSeverityIcon, loadFileIcon) + : new ObservableCollection(); + findingsControl.SetAllFileNodes(fileNodes); + }, "Coordinator.RefreshProblemWindow"); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorHandler.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorHandler.cs new file mode 100644 index 00000000..2ed180e6 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorHandler.cs @@ -0,0 +1,68 @@ +using System; +using System.Diagnostics; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Central error handling for CxAssist so third-party plugins or VS errors + /// do not crash gutter, underline, problem window, or hover. + /// We log and swallow exceptions at VS callback boundaries (GetTags, GenerateGlyph, etc.). + /// + internal static class CxAssistErrorHandler + { + /// + /// Logs the exception and returns without rethrowing. + /// Use at VS/extension callback boundaries (GetTags, GenerateGlyph, event handlers) + /// so our code never throws into VS or other extensions. + /// + /// The exception (can be from our code, third-party, or VS). + /// Short description of where it happened (e.g. "GlyphTagger.GetTags"). + public static void LogAndSwallow(Exception ex, string context) + { + if (ex == null) return; + try + { + Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {context}: {ex.Message}"); + Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {ex.StackTrace}"); + } + catch + { + // Do not throw from error handler + } + } + + /// + /// Wraps an action in try-catch; on exception logs and swallows (does not rethrow). + /// Returns true if the action ran without exception, false otherwise. + /// + public static bool TryRun(Action action, string context) + { + try + { + action?.Invoke(); + return true; + } + catch (Exception ex) + { + LogAndSwallow(ex, context); + return false; + } + } + + /// + /// Tries to run a function; on exception logs, swallows, and returns default(T). + /// + public static T TryGet(Func func, string context, T defaultValue = default) + { + try + { + return func != null ? func() : defaultValue; + } + catch (Exception ex) + { + LogAndSwallow(ex, context); + return defaultValue; + } + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorListSync.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorListSync.cs new file mode 100644 index 00000000..a815b75e --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorListSync.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using EnvDTE; +using EnvDTE80; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Syncs CxAssist findings to the built-in Error List so issues appear in both + /// the custom CxAssist findings window and the VS Error List. + /// + internal sealed class CxAssistErrorListSync + { + /// Prefix stored in ErrorTask.HelpKeyword so we can identify CxAssist tasks and recover vulnerability Id. + public const string HelpKeywordPrefix = "CxAssist:"; + + private ErrorListProvider _errorListProvider; + private bool _subscribed; + + public void Start() + { + if (_subscribed) return; + + ThreadHelper.ThrowIfNotOnUIThread(); + EnsureErrorListProvider(); + CxAssistDisplayCoordinator.IssuesUpdated += OnIssuesUpdated; + _subscribed = true; + + // Initial sync from current state + var snapshot = CxAssistDisplayCoordinator.GetAllIssuesByFile(); + if (snapshot != null && snapshot.Count > 0) + SyncToErrorList(snapshot); + } + + public void Stop() + { + if (!_subscribed) return; + + CxAssistDisplayCoordinator.IssuesUpdated -= OnIssuesUpdated; + _subscribed = false; + + ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + _errorListProvider?.Tasks.Clear(); + }); + } + + private void OnIssuesUpdated(IReadOnlyDictionary> snapshot) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + SyncToErrorList(snapshot); + }); + } + + private void EnsureErrorListProvider() + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (_errorListProvider != null) return; + + _errorListProvider = new ErrorListProvider(ServiceProvider.GlobalProvider) + { + ProviderName = CxAssistConstants.DisplayName + }; + } + + private void SyncToErrorList(IReadOnlyDictionary> issuesByFile) + { + ThreadHelper.ThrowIfNotOnUIThread(); + EnsureErrorListProvider(); + + _errorListProvider.Tasks.Clear(); + + if (issuesByFile == null || issuesByFile.Count == 0) + return; + + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + if (dte?.Documents == null) return; + + foreach (var kv in issuesByFile) + { + string filePath = kv.Key; + var list = kv.Value; + if (list == null) continue; + + Document document = null; + try + { + document = dte.Documents.Cast().FirstOrDefault(doc => + string.Equals(doc.FullName, filePath, StringComparison.OrdinalIgnoreCase)); + } + catch + { + // Document may not be open + } + + // Build entries like the Findings tree: same-line grouping for IaC and ASCA (one row per line when 2+ issues) + var entries = BuildErrorListEntries(list); + string docPath = GetDocumentPath(list.Count > 0 ? list[0].FilePath : null, filePath); + + foreach (var entry in entries) + { + var v = entry.Vulnerability; + // Same description format as Findings tab: PrimaryDisplayText + " Checkmarx One Assist [Ln X, Col Y]" + int displayLine = entry.Line + 1; // 1-based for description text to match Findings + string fullDescription = $"{entry.DisplayText} {CxAssistConstants.DisplayName} [Ln {displayLine}, Col {entry.Column}]"; + var task = new ErrorTask + { + Category = TaskCategory.BuildCompile, + ErrorCategory = GetErrorCategory(v.Severity), + Text = fullDescription, + Document = docPath, + Line = entry.Line, + Column = Math.Max(1, entry.Column), + HierarchyItem = document != null ? GetHierarchyItem(document) : null, + HelpKeyword = HelpKeywordPrefix + v.Id + }; + + task.Navigate += (s, e) => NavigateToVulnerability(v); + _errorListProvider.Tasks.Add(task); + } + } + + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.ERROR_LIST_SYNCED, _errorListProvider.Tasks.Count, issuesByFile.Count)); + } + + /// + /// Builds Error List entries with same-line grouping as the Findings tree: all scanners + /// show one entry per line when multiple issues share a line (e.g. "N OSS issues detected on this line"). + /// Vulnerability.LineNumber is 1-based. We convert to 0-based for Error List (ErrorTask.Line); + /// VS displays that as 1-based in the UI, so the column matches "[Ln X, Col Y]" in the Findings tab. + /// + private static List<(string DisplayText, int Line, int Column, Vulnerability Vulnerability)> BuildErrorListEntries(List list) + { + var result = new List<(string, int, int, Vulnerability)>(); + // Aligned with JetBrains isProblem: show in Error List / Problems only for problem severities (not Ok, Unknown, Ignored). + var issuesOnly = list.Where(v => CxAssistConstants.IsProblem(v.Severity)).ToList(); + + // Error List expects 0-based line (VS shows 1-based in UI). Convert 1-based LineNumber to 0-based. + int LineForErrorList(ScannerType scanner, int line1Based) => CxAssistConstants.To0BasedLineForEditor(scanner, line1Based); + int ColForErrorList(int c) => Math.Max(1, c); + + // IaC: group by line (same as Findings tree). + foreach (var lineGroup in issuesOnly.Where(v => v.Scanner == ScannerType.IaC).GroupBy(v => v.LineNumber)) + { + var lineList = lineGroup.ToList(); + var first = lineList[0]; + int line0Based = LineForErrorList(ScannerType.IaC, first.LineNumber); + if (lineList.Count > 1) + result.Add((lineList.Count + CxAssistConstants.MultipleIacIssuesOnLine, line0Based, ColForErrorList(first.ColumnNumber), first)); + else + result.Add((GetPrimaryDisplayText(first.Severity, first.Scanner, first.Title ?? first.Description, first.PackageName, first.PackageVersion), line0Based, ColForErrorList(first.ColumnNumber), first)); + } + + // ASCA: group by line; multiple on same line → show highest-severity detail only (same as Findings) + foreach (var lineGroup in issuesOnly.Where(v => v.Scanner == ScannerType.ASCA).GroupBy(v => v.LineNumber)) + { + var lineList = lineGroup.ToList(); + var v = lineList.Count > 1 ? lineList.OrderBy(x => x.Severity).First() : lineList[0]; + result.Add((GetPrimaryDisplayText(v.Severity, v.Scanner, v.Title ?? v.Description, v.PackageName, v.PackageVersion), LineForErrorList(v.Scanner, v.LineNumber), ColForErrorList(v.ColumnNumber), v)); + } + + // OSS: group by line; multiple on same line → show highest-severity detail only (same as Findings) + foreach (var lineGroup in issuesOnly.Where(v => v.Scanner == ScannerType.OSS).GroupBy(v => v.LineNumber)) + { + var lineList = lineGroup.ToList(); + var v = lineList.Count > 1 ? lineList.OrderBy(x => x.Severity).First() : lineList[0]; + result.Add((GetPrimaryDisplayText(v.Severity, v.Scanner, v.Title ?? v.Description, v.PackageName, v.PackageVersion), LineForErrorList(v.Scanner, v.LineNumber), ColForErrorList(v.ColumnNumber), v)); + } + + // Secrets: group by line; multiple on same line → show highest-severity detail only + foreach (var lineGroup in issuesOnly.Where(v => v.Scanner == ScannerType.Secrets).GroupBy(v => v.LineNumber)) + { + var lineList = lineGroup.ToList(); + var v = lineList.Count > 1 ? lineList.OrderBy(x => x.Severity).First() : lineList[0]; + result.Add((GetPrimaryDisplayText(v.Severity, v.Scanner, v.Title ?? v.Description, v.PackageName, v.PackageVersion), LineForErrorList(v.Scanner, v.LineNumber), ColForErrorList(v.ColumnNumber), v)); + } + + // Containers: group by line; multiple on same line → show highest-severity detail only + foreach (var lineGroup in issuesOnly.Where(v => v.Scanner == ScannerType.Containers).GroupBy(v => v.LineNumber)) + { + var lineList = lineGroup.ToList(); + var v = lineList.Count > 1 ? lineList.OrderBy(x => x.Severity).First() : lineList[0]; + result.Add((GetPrimaryDisplayText(v.Severity, v.Scanner, v.Title ?? v.Description, v.PackageName, v.PackageVersion), LineForErrorList(v.Scanner, v.LineNumber), ColForErrorList(v.ColumnNumber), v)); + } + + return result; + } + + /// + /// Builds the same primary description text as the Findings tab (VulnerabilityNode.PrimaryDisplayText) + /// so the Error List description column matches the Findings tree. + /// + private static string GetPrimaryDisplayText(SeverityLevel severity, ScannerType scanner, string titleOrDescription, string packageName, string packageVersion) + { + string title = titleOrDescription ?? ""; + if (title.Contains(" detected on this line") || title.Contains(" violations detected on this line")) + return title.TrimEnd(); + string severityStr = severity.ToString(); + switch (scanner) + { + case ScannerType.OSS: + string name = !string.IsNullOrEmpty(title) ? title : (packageName ?? ""); + name = CxAssistConstants.StripCveFromDisplayName(name); + string version = !string.IsNullOrEmpty(packageVersion) ? "@" + packageVersion : ""; + return $"{severityStr}-risk package: {name}{version}"; + case ScannerType.Secrets: + return $"{severityStr}-risk secret: {title}"; + case ScannerType.Containers: + return $"{severityStr}-risk container image: {title}"; + case ScannerType.ASCA: + case ScannerType.IaC: + default: + return title + (string.IsNullOrEmpty(title) ? "" : " "); + } + } + + /// + /// Returns a normalized full path for the Error List so VS shows the actual file name instead of "Document 1". + /// + private static string GetDocumentPath(string vulnerabilityFilePath, string fallbackFilePath) + { + string path = !string.IsNullOrEmpty(vulnerabilityFilePath) ? vulnerabilityFilePath : fallbackFilePath; + if (string.IsNullOrEmpty(path)) return null; + try + { + return Path.GetFullPath(path); + } + catch + { + return path; + } + } + + /// + /// Use Error for all findings so the Error List draws only red underlines. Otherwise + /// Warning (green) and Message (blue) on the same line can override red and make severity unclear. + /// Severity is still shown in the task Text (e.g. [High], [Medium]). + /// + private static TaskErrorCategory GetErrorCategory(SeverityLevel severity) + { + return TaskErrorCategory.Error; + } + + private static IVsHierarchy GetHierarchyItem(Document document) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (document?.ProjectItem?.ContainingProject == null) return null; + + var serviceProvider = ServiceProvider.GlobalProvider; + var solution = serviceProvider.GetService(typeof(SVsSolution)) as IVsSolution; + if (solution == null) return null; + + solution.GetProjectOfUniqueName(document.ProjectItem.ContainingProject.UniqueName, out IVsHierarchy hierarchy); + return hierarchy; + } + + /// Called when user navigates from Error List task or from Error List context menu. + internal static void NavigateToVulnerability(Vulnerability v) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (string.IsNullOrEmpty(v?.FilePath)) return; + + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + if (dte == null) + return; + + try + { + Window window = null; + string pathToTry = v.FilePath; + + window = dte.ItemOperations.OpenFile(pathToTry, EnvDTE.Constants.vsViewKindCode); + if (window == null && !Path.IsPathRooted(pathToTry)) + { + try + { + pathToTry = Path.GetFullPath(pathToTry); + window = dte.ItemOperations.OpenFile(pathToTry, EnvDTE.Constants.vsViewKindCode); + } + catch { /* ignore */ } + } + if (window == null && dte.Solution != null) + { + try + { + string solDir = Path.GetDirectoryName(dte.Solution.FullName); + if (!string.IsNullOrEmpty(solDir)) + { + string pathInSolution = Path.Combine(solDir, Path.GetFileName(v.FilePath)); + if (pathInSolution != pathToTry) + window = dte.ItemOperations.OpenFile(pathInSolution, EnvDTE.Constants.vsViewKindCode); + } + } + catch { /* ignore */ } + } + if (window == null && dte.Documents != null) + { + string fileName = Path.GetFileName(pathToTry); + Document doc = dte.Documents.Cast().FirstOrDefault(d => + string.Equals(d.FullName, pathToTry, StringComparison.OrdinalIgnoreCase) + || string.Equals(Path.GetFileName(d.FullName), fileName, StringComparison.OrdinalIgnoreCase)); + if (doc != null) + window = doc.ActiveWindow; + } + + if (window?.Document?.Object("TextDocument") is TextDocument textDoc) + { + var selection = textDoc.Selection; + int line = CxAssistConstants.To1BasedLineForDte(v.Scanner, v.LineNumber); + selection.MoveToLineAndOffset(line, Math.Max(1, v.ColumnNumber)); + selection.SelectLine(); + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "ErrorListSync.NavigateToVulnerability"); + } + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistMockData.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistMockData.cs new file mode 100644 index 00000000..1a842432 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistMockData.cs @@ -0,0 +1,1748 @@ +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Windows.Media; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Common mock data used to demonstrate all four POC features: + /// underline (squiggle), gutter icon, problem window, and popup hover. + /// One source of truth so editor and findings window show the same data. + /// + public static class CxAssistMockData + { + /// Default file path used for mock vulnerabilities (editor and findings window). + public const string DefaultFilePath = "Program.cs"; + + /// Vulnerability Id that uses standard Quick Info popup only (no custom hover popup). + public const string QuickInfoOnlyVulnerabilityId = "POC-007"; + + /// + /// Returns the common list of mock vulnerabilities used for: + /// - Gutter icons (severity-specific icons on lines 1, 3, 5, 7, 9) + /// - Underline (squiggles on the same lines) + /// - Popup hover (hover over those lines to see rich popup with OSS/ASCA content) + /// - Problem window (when converted to FileNodes via BuildFileNodesFromVulnerabilities) + /// + /// Optional file path; if null or empty, uses DefaultFilePath. + public static List GetCommonVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? DefaultFilePath : filePath; + + return new List + { + // Line 1 – Malicious (OSS) – shows in gutter, underline, hover, problem window + new Vulnerability + { + Id = "POC-001", + Title = "Malicious Package", + Description = "Test Malicious vulnerability – known malicious package in dependencies.", + Severity = SeverityLevel.Malicious, + Scanner = ScannerType.OSS, + LineNumber = 1, + ColumnNumber = 0, + FilePath = path, + PackageName = "node-ipc", + PackageVersion = "10.1.1", + RecommendedVersion = "10.2.0", + CveName = "CVE-Malicious-Example", + CvssScore = 9.8, + LearnMoreUrl = "https://example.com/cve" + }, + // Line 3 – Critical (ASCA) + new Vulnerability + { + Id = "POC-002", + Title = "SQL Injection", + Description = "Test Critical vulnerability – user input concatenated into SQL without sanitization.", + Severity = SeverityLevel.Critical, + Scanner = ScannerType.ASCA, + LineNumber = 3, + ColumnNumber = 0, + FilePath = path, + RuleName = "SQL_INJECTION", + RemediationAdvice = "Use parameterized queries or prepared statements." + }, + // Line 5 – High (OSS) – first of two on same line (severity count in popup) + new Vulnerability + { + Id = "POC-003", + Title = "High-Risk Package", + Description = "Test High vulnerability – vulnerable version of package.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 5, + ColumnNumber = 0, + FilePath = path, + PackageName = "lodash", + PackageVersion = "4.17.15", + RecommendedVersion = "4.17.21", + CveName = "CVE-2020-8203", + CvssScore = 7.4 + }, + // Line 5 – Medium (second on same line) + new Vulnerability + { + Id = "POC-004", + Title = "Medium Severity Finding", + Description = "Test Medium vulnerability on same line as High.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.ASCA, + LineNumber = 5, + ColumnNumber = 0, + FilePath = path, + RuleName = "WEAK_CRYPTO", + RemediationAdvice = "Use a stronger algorithm." + }, + // Line 7 – Medium (OSS) + new Vulnerability + { + Id = "POC-005", + Title = "Outdated Dependency", + Description = "Test Medium vulnerability – dependency has a known issue.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 7, + ColumnNumber = 0, + FilePath = path, + PackageName = "axios", + PackageVersion = "0.21.0", + RecommendedVersion = "0.27.0" + }, + // Line 9 – Low + new Vulnerability + { + Id = "POC-006", + Title = "Low Severity", + Description = "Test Low vulnerability – minor finding.", + Severity = SeverityLevel.Low, + Scanner = ScannerType.OSS, + LineNumber = 9, + ColumnNumber = 0, + FilePath = path, + PackageName = "debug", + PackageVersion = "2.6.9" + }, + // Line 11 – Quick Info only (no custom hover popup): shows standard VS Quick Info with rich text and links + new Vulnerability + { + Id = QuickInfoOnlyVulnerabilityId, + Title = "High Severity Finding", + Description = "This finding uses the standard Quick Info popup: Checkmarx One Assist badge, rich severity name, description, and action links (Fix with Checkmarx Assist, View Details, Ignore vulnerability).", + Severity = SeverityLevel.High, + Scanner = ScannerType.ASCA, + LineNumber = 11, + ColumnNumber = 0, + FilePath = path, + RuleName = "QUICK_INFO_DEMO", + RemediationAdvice = "Use the Quick Info links to fix, view details, or ignore." + }, + // Line 13 – Quick Info only: 2 vulnerabilities on same line (no custom popup; hover shows Quick Info for first) + new Vulnerability + { + Id = QuickInfoOnlyVulnerabilityId, + Title = "First finding on line (Critical)", + Description = "First of two Quick-Info-only findings on this line. Critical severity – sensitive data exposure risk.", + Severity = SeverityLevel.Critical, + Scanner = ScannerType.ASCA, + LineNumber = 13, + ColumnNumber = 0, + FilePath = path, + RuleName = "SENSITIVE_DATA", + RemediationAdvice = "Avoid logging or exposing sensitive data." + }, + new Vulnerability + { + Id = QuickInfoOnlyVulnerabilityId, + Title = "Second finding on line (Medium)", + Description = "Second of two Quick-Info-only findings on this line. Medium severity – weak cryptographic usage.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.ASCA, + LineNumber = 13, + ColumnNumber = 0, + FilePath = path, + RuleName = "WEAK_CRYPTO", + RemediationAdvice = "Use a stronger algorithm." + }, + // Line 15 – Quick Info only (single finding) + new Vulnerability + { + Id = QuickInfoOnlyVulnerabilityId, + Title = "Quick Info – Outdated dependency", + Description = "Quick-Info-only demo: outdated package with known CVE. Use standard Quick Info links to fix or view details.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 15, + ColumnNumber = 0, + FilePath = path, + PackageName = "minimist", + PackageVersion = "1.2.0", + RecommendedVersion = "1.2.6", + CveName = "CVE-2022-21803" + }, + // Line 17 – Quick Info only (single finding) + new Vulnerability + { + Id = QuickInfoOnlyVulnerabilityId, + Title = "Quick Info – Low severity", + Description = "Quick-Info-only demo: low-severity finding. Only the standard Quick Info popup is shown here.", + Severity = SeverityLevel.Low, + Scanner = ScannerType.ASCA, + LineNumber = 17, + ColumnNumber = 0, + FilePath = path, + RuleName = "LOW_SEVERITY_DEMO", + RemediationAdvice = "Consider addressing in next sprint." + } + }; + } + + /// + /// Returns mock OSS-style vulnerabilities for package.json (gutter, underline, problem window, Error List, popup). + /// Line numbers and StartIndex/EndIndex match AST-CLI OSS realtime scan output (Locations per package). + /// Includes Status=OK (success gutter icon) and Status=Unknown (unknown icon) per reference behavior. + /// + /// Optional file path; if null or empty, uses "package.json". + public static List GetPackageJsonMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "package.json" : filePath; + + return new List + { + // OSS no vul (Status OK) – success gutter icon; Locations from scan + new Vulnerability + { + Id = "OSS-ok-nyc-config-typescript", + Title = "@istanbuljs/nyc-config-typescript (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 9, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 49, + FilePath = path, + PackageName = "@istanbuljs/nyc-config-typescript", + PackageVersion = "1.0.2", + PackageManager = "npm" + }, + new Vulnerability + { + Id = "OSS-ok-webpack-cli", + Title = "webpack-cli (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 11, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 27, + FilePath = path, + PackageName = "webpack-cli", + PackageVersion = "5.1.4", + PackageManager = "npm" + }, + new Vulnerability + { + Id = "OSS-ok-popperjs", + Title = "@popperjs/core (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 15, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 32, + FilePath = path, + PackageName = "@popperjs/core", + PackageVersion = "2.11.8", + PackageManager = "npm" + }, + new Vulnerability + { + Id = "OSS-ok-minimist", + Title = "minimist (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 17, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 24, + FilePath = path, + PackageName = "minimist", + PackageVersion = "1.2.6", + PackageManager = "npm" + }, + // OSS unknown status – unknown gutter icon + new Vulnerability + { + Id = "OSS-unknown-ast-cli-wrapper", + Title = "@checkmarxdev/ast-cli-javascript-wrapper (Unknown)", + Description = "Unknown status.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 10, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 74, + FilePath = path, + PackageName = "@checkmarxdev/ast-cli-javascript-wrapper", + PackageVersion = "0.0.131", + PackageManager = "npm" + }, + // Line 13 – validator (2 CVEs); Locations: StartIndex 4, EndIndex 27 + new Vulnerability + { + Id = "CVE-2025-12758", + Title = "validator (CVE-2025-12758)", + Description = "Incomplete Filtering in isLength() function.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 14, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 27, + FilePath = path, + PackageName = "validator", + PackageVersion = "13.12.0", + PackageManager = "npm", + CveName = "CVE-2025-12758", + RecommendedVersion = "13.15.22" + }, + new Vulnerability + { + Id = "CVE-2025-56200", + Title = "validator (CVE-2025-56200)", + Description = "A URL validation bypass vulnerability exists in validator.js.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 14, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 27, + FilePath = path, + PackageName = "validator", + PackageVersion = "13.12.0", + PackageManager = "npm", + CveName = "CVE-2025-56200", + RecommendedVersion = "13.15.16" + }, + // Line 15 – lodash; Locations: StartIndex 4, EndIndex 24 + new Vulnerability + { + Id = "CVE-2025-13465", + Title = "lodash (CVE-2025-13465)", + Description = "Prototype Pollution in _.unset and _.omit.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 16, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 24, + FilePath = path, + PackageName = "lodash", + PackageVersion = "4.17.21", + PackageManager = "npm", + CveName = "CVE-2025-13465", + RecommendedVersion = "4.17.23" + }, + // Line 17 – moment (2 CVEs); Locations: StartIndex 4, EndIndex 23 + new Vulnerability + { + Id = "CVE-2022-24785", + Title = "moment (CVE-2022-24785)", + Description = "Path traversal vulnerability in Moment.js.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 18, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 23, + FilePath = path, + PackageName = "moment", + PackageVersion = "2.18.0", + PackageManager = "npm", + CveName = "CVE-2022-24785", + RecommendedVersion = "2.29.2" + }, + new Vulnerability + { + Id = "CVE-2022-31129", + Title = "moment (CVE-2022-31129)", + Description = "ReDoS via inefficient parsing algorithm.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 18, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 23, + FilePath = path, + PackageName = "moment", + PackageVersion = "2.18.0", + PackageManager = "npm", + CveName = "CVE-2022-31129", + RecommendedVersion = "2.29.4" + }, + // Line 18 – request; Locations: StartIndex 4, EndIndex 24 + new Vulnerability + { + Id = "CVE-2023-28155", + Title = "request (CVE-2023-28155)", + Description = "SSRF mitigations bypass via cross-protocol redirect.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 19, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 24, + FilePath = path, + PackageName = "request", + PackageVersion = "2.88.2", + PackageManager = "npm", + CveName = "CVE-2023-28155" + }, + // Line 19 – node-ipc (Malicious); Locations: StartIndex 4, EndIndex 23 + new Vulnerability + { + Id = "OSS-node-ipc-10.1.1-Malicious", + Title = "node-ipc (Malicious)", + Description = "Malicious package: node-ipc@10.1.1.", + Severity = SeverityLevel.Malicious, + Scanner = ScannerType.OSS, + LineNumber = 20, + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 23, + FilePath = path, + PackageName = "node-ipc", + PackageVersion = "10.1.1", + PackageManager = "npm" + } + }; + } + + /// + /// Returns mock OSS-style vulnerabilities for pom.xml (mvn) to simulate gutter icons, underlines and problem window entries. + /// Dependencies and statuses mirror the sample scan output provided in the issue report. + /// + /// Optional file path; if null or empty, uses "pom.xml". + public static List GetPomMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "pom.xml" : filePath; + + return new List + { + new Vulnerability + { + Id = "OSS-mockito", + Title = "org.mockito:mockito-core (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 66, + EndLineNumber = 71, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.mockito:mockito-core", + PackageVersion = "latest", + PackageManager = "mvn" + }, + new Vulnerability + { + Id = "OSS-cx-integrations-common", + Title = "com.checkmarx:cx-integrations-common (Unknown)", + Description = "Unknown status.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 71, + EndLineNumber = 77, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "com.checkmarx:cx-integrations-common", + PackageVersion = "0.0.319", + PackageManager = "mvn" + }, + new Vulnerability + { + Id = "OSS-cx-interceptors-lib", + Title = "com.checkmarx:cx-interceptors-lib (Unknown)", + Description = "Unknown status.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 77, + EndLineNumber = 82, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "com.checkmarx:cx-interceptors-lib", + PackageVersion = "0.1.58", + PackageManager = "mvn" + }, + new Vulnerability + { + Id = "OSS-httpclient5", + Title = "org.apache.httpcomponents.client5:httpclient5 (Unknown)", + Description = "Unknown status.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 25, + EndLineNumber = 30, + ColumnNumber = 12, + StartIndex = 12, + EndIndex = 25, + FilePath = path, + PackageName = "org.apache.httpcomponents.client5:httpclient5", + PackageVersion = "5.4.3", + PackageManager = "mvn" + }, + new Vulnerability + { + Id = "OSS-httpclient5-fluent", + Title = "org.apache.httpcomponents.client5:httpclient5-fluent (Unknown)", + Description = "Unknown status.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 30, + EndLineNumber = 34, + ColumnNumber = 12, + StartIndex = 12, + EndIndex = 25, + FilePath = path, + PackageName = "org.apache.httpcomponents.client5:httpclient5-fluent", + PackageVersion = "5.4.3", + PackageManager = "mvn" + }, + new Vulnerability + { + Id = "OSS-lombok", + Title = "org.projectlombok:lombok (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 91, + EndLineNumber = 95, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.projectlombok:lombok", + PackageVersion = "latest", + PackageManager = "mvn" + }, + // commons-compress: gutter and popup on first line (94), underline on all lines of block (94–98) + new Vulnerability + { + Id = "CVE-2023-42503", + Title = "org.apache.commons:commons-compress (CVE-2023-42503)", + Description = "Improper Input Validation, Uncontrolled Resource Consumption in Apache Commons Compress TAR parsing.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 95, + ColumnNumber = 8, + FilePath = path, + PackageName = "org.apache.commons:commons-compress", + PackageVersion = "1.23.0", + PackageManager = "mvn", + CveName = "CVE-2023-42503", + RecommendedVersion = "1.23.1", + // 0-based StartIndex/EndIndex per line to match test-data pom.xml lines 94–98 + Locations = new List + { + new VulnerabilityLocation { Line = 95, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 96, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 97, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 98, StartIndex = 12, EndIndex = 37 }, // "1.23.0" in + new VulnerabilityLocation { Line = 99, StartIndex = 8, EndIndex = 20 } // " " + } + }, + new Vulnerability + { + Id = "CVE-2024-26308", + Title = "org.apache.commons:commons-compress (CVE-2024-26308)", + Description = "Allocation of Resources Without Limits or Throttling vulnerability in Apache Commons Compress.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 95, + ColumnNumber = 12, + FilePath = path, + PackageName = "org.apache.commons:commons-compress", + PackageVersion = "1.23.0", + PackageManager = "mvn", + CveName = "CVE-2024-26308", + RecommendedVersion = "1.23.1", + Locations = new List + { + new VulnerabilityLocation { Line = 95, StartIndex = 8, EndIndex = 20 }, + new VulnerabilityLocation { Line = 96, StartIndex = 12, EndIndex = 54 }, + new VulnerabilityLocation { Line = 97, StartIndex = 12, EndIndex = 54 }, + new VulnerabilityLocation { Line = 98, StartIndex = 12, EndIndex = 37 }, // "1.23.0" in + new VulnerabilityLocation { Line = 99, StartIndex = 8, EndIndex = 20 } + } + }, + new Vulnerability + { + Id = "CVE-2024-25710", + Title = "org.apache.commons:commons-compress (CVE-2024-25710)", + Description = "Loop with Unreachable Exit Condition ('Infinite Loop') vulnerability in Apache Commons Compress.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 95, + ColumnNumber = 12, + FilePath = path, + PackageName = "org.apache.commons:commons-compress", + PackageVersion = "1.23.0", + PackageManager = "mvn", + CveName = "CVE-2024-25710", + RecommendedVersion = "1.23.1", + Locations = new List + { + new VulnerabilityLocation { Line = 95, StartIndex = 8, EndIndex = 20 }, + new VulnerabilityLocation { Line = 96, StartIndex = 12, EndIndex = 54 }, + new VulnerabilityLocation { Line = 97, StartIndex = 12, EndIndex = 54 }, + new VulnerabilityLocation { Line = 98, StartIndex = 12, EndIndex = 37 }, + new VulnerabilityLocation { Line = 99, StartIndex = 8, EndIndex = 20 } + } + }, + new Vulnerability + { + Id = "OSS-snakeyaml", + Title = "org.yaml:snakeyaml (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 100, + EndLineNumber = 102, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.yaml:snakeyaml", + PackageVersion = "latest", + PackageManager = "mvn" + }, + // tomcat-embed-core: gutter on first line (103), underline on all lines of block (103–107) + new Vulnerability + { + Id = "CVE-2025-46701", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-46701)", + Description = "Improper Handling of Case Sensitivity in Apache Tomcat's CGI servlet allowing security constraint bypass.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-46701", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2026-24734", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2026-24734)", + Description = "Improper Input Validation vulnerability in Apache Tomcat Native/OCSP handling.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2026-24734", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2024-23672", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2024-23672)", + Description = "Denial of Service via incomplete cleanup in Apache Tomcat WebSocket clients.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2024-23672", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2024-50379", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2024-50379)", + Description = "TOCTOU Race Condition during JSP compilation permitting RCE on case-insensitive file systems.", + Severity = SeverityLevel.Critical, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2024-50379", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2024-24549", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2024-24549)", + Description = "HTTP/2 CONTINUATION Flood leading to denial of service in Apache Tomcat.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2024-24549", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2026-24733", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2026-24733)", + Description = "Improper Input Validation vulnerability limiting HTTP/0.9 handling in Tomcat.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2026-24733", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2024-38286", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2024-38286)", + Description = "Allocation of Resources Without Limits or Throttling via TLS handshake leading to OOM.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2024-38286", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-31651", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-31651)", + Description = "Improper Neutralization of Escape/Meta Sequences vulnerability in Apache Tomcat.", + Severity = SeverityLevel.Critical, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-31651", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2024-34750", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2024-34750)", + Description = "Uncontrolled Resource Consumption when processing HTTP/2 streams in Apache Tomcat.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2024-34750", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-55752", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-55752)", + Description = "Relative Path Traversal vulnerability in Apache Tomcat allowing possible bypass of protections.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-55752", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-52520", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-52520)", + Description = "Integer Overflow in multipart upload handling could lead to DoS in Apache Tomcat.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-52520", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-61795", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-61795)", + Description = "Improper Resource Shutdown or Release vulnerability in Apache Tomcat multipart handling.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-61795", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-66614", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-66614)", + Description = "Improper Input Validation vulnerability in Apache Tomcat.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-66614", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2024-52316", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2024-52316)", + Description = "Unchecked Error Condition vulnerability in Apache Tomcat's authentication flow.", + Severity = SeverityLevel.Critical, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2024-52316", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-48988", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-48988)", + Description = "Allocation of Resources Without Limits or Throttling vulnerability in Apache Tomcat.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-48988", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-55668", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-55668)", + Description = "Session Fixation vulnerability via rewrite valve in Apache Tomcat.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-55668", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-31650", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-31650)", + Description = "Improper Input Validation vulnerability was found in Apache Tomcat causing memory leak.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-31650", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-49125", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-49125)", + Description = "Authentication Bypass Using an Alternate Path or Channel vulnerability in Apache Tomcat.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-49125", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + new Vulnerability + { + Id = "CVE-2025-53506", + Title = "org.apache.tomcat.embed:tomcat-embed-core (CVE-2025-53506)", + Description = "Uncontrolled Resource Consumption vulnerability in Apache Tomcat related to HTTP/2 settings.", + Severity = SeverityLevel.High, + Scanner = ScannerType.OSS, + LineNumber = 104, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.apache.tomcat.embed:tomcat-embed-core", + PackageVersion = "latest", + PackageManager = "mvn", + CveName = "CVE-2025-53506", + Locations = new List + { + new VulnerabilityLocation { Line = 104, StartIndex = 8, EndIndex = 20 }, // " " + new VulnerabilityLocation { Line = 105, StartIndex = 12, EndIndex = 54 }, // "org.apache.commons" in + new VulnerabilityLocation { Line = 106, StartIndex = 12, EndIndex = 54 }, // "1.23.0" in + new VulnerabilityLocation { Line = 107, StartIndex = 8, EndIndex = 20 }, // "1.23.0" in + } + }, + // spring-boot-starter-web (OK) + new Vulnerability + { + Id = "OSS-spring-boot-starter-web", + Title = "org.springframework.boot:spring-boot-starter-web (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 107, + EndLineNumber = 120, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "org.springframework.boot:spring-boot-starter-web", + PackageVersion = "latest", + PackageManager = "mvn" + }, + // jackson-dataformat-smile: gutter on first line (123), underline on all lines of block (123–126) + new Vulnerability + { + Id = "OSS-jackson-dataformat-smile", + Title = "com.fasterxml.jackson.dataformat:jackson-dataformat-smile (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 121, + EndLineNumber = 125, + ColumnNumber = 8, + StartIndex = 8, + EndIndex = 21, + FilePath = path, + PackageName = "com.fasterxml.jackson.dataformat:jackson-dataformat-smile", + PackageVersion = "2.18.2", + PackageManager = "mvn" + } + }; + } + + /// + /// Returns mock Secrets + ASCA vulnerabilities for secrets.py (gutter, underline, problem window, Error List, popup). + /// Matches Secrets realtime scan shape: generic-api-key (line 5), github-pat (line 7), private-key (lines 17–19), plus ASCA findings. + /// + /// Optional file path; if null or empty, uses "secrets.py". + public static List GetSecretsPyMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "secrets.py" : filePath; + var list = new List(); + + // --- Secrets (from realtime scan JSON; Locations with StartIndex/EndIndex) --- + list.Add(new Vulnerability + { + Id = "generic-api-key", + Title = "generic-api-key", + Description = "Detected a Generic API Key, potentially exposing access to various services and sensitive operations.", + Severity = SeverityLevel.High, + Scanner = ScannerType.Secrets, + LineNumber = 6, + ColumnNumber = 2, + StartIndex = 1, + EndIndex = 43, + FilePath = path, + SecretType = "Generic API Key" + }); + list.Add(new Vulnerability + { + Id = "github-pat", + Title = "github-pat", + Description = "Uncovered a GitHub Personal Access Token, potentially leading to unauthorized repository access and sensitive content exposure.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.Secrets, + LineNumber = 9, + ColumnNumber = 18, + StartIndex = 17, + EndIndex = 56, + FilePath = path, + SecretType = "GitHub PAT" + }); + list.Add(new Vulnerability + { + Id = "private-key-17", + Title = "private-key", + Description = "Identified a Private Key, which may compromise cryptographic security and sensitive data encryption.", + Severity = SeverityLevel.High, + Scanner = ScannerType.Secrets, + LineNumber = 18, + ColumnNumber = 2, + StartIndex = 1, + EndIndex = 29, + FilePath = path, + SecretType = "Private Key" + }); + + // --- ASCA (SAST-style for same file) --- + list.Add(new Vulnerability + { + Id = "ASCA-SECRETS-001", + Title = "Hardcoded credential", + Description = "Hardcoded credential detected; use a secrets manager or environment variables.", + Severity = SeverityLevel.High, + Scanner = ScannerType.ASCA, + LineNumber = 11, + ColumnNumber = 1, + FilePath = path, + RuleName = "HARDCODED_CREDENTIAL", + RemediationAdvice = "Store secrets in a secure vault or environment variables." + }); + list.Add(new Vulnerability + { + Id = "ASCA-SECRETS-002", + Title = "Insecure deserialization", + Description = "User input passed to deserialization may lead to code execution.", + Severity = SeverityLevel.Critical, + Scanner = ScannerType.ASCA, + LineNumber = 14, + ColumnNumber = 1, + FilePath = path, + RuleName = "INSECURE_DESERIALIZATION", + RemediationAdvice = "Avoid deserializing untrusted data; use allowlists or safe formats." + }); + + return list; + } + + /// + /// Returns mock vulnerabilities for Gradle build files (build.gradle / build.gradle.kts). + /// + public static List GetBuildGradleMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "build.gradle" : filePath; + return new List + { + new Vulnerability + { + Id = "GRADLE-httpclient5", + Title = "org.apache.httpcomponents.client5:httpclient5 (Unknown)", + Description = "Unknown status from mock scan.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.OSS, + LineNumber = 10, + ColumnNumber = 4, + FilePath = path, + PackageName = "org.apache.httpcomponents.client5:httpclient5", + PackageVersion = "5.4.3", + PackageManager = "gradle" + }, + new Vulnerability + { + Id = "GRADLE-lombok", + Title = "org.projectlombok:lombok (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 14, + ColumnNumber = 4, + FilePath = path, + PackageName = "org.projectlombok:lombok", + PackageVersion = "latest", + PackageManager = "gradle" + } + }; + } + + /// + /// Returns mock vulnerabilities for Python requirements-style manifests. + /// + public static List GetRequirementsMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "requirements.txt" : filePath; + return new List + { + new Vulnerability + { + Id = "PY-CVE-2024-99999", + Title = "requests (CVE-2024-99999)", + Description = "Mock vulnerability in requests package.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.OSS, + LineNumber = 3, + ColumnNumber = 1, + StartIndex = 0, + EndIndex = 10, + FilePath = path, + PackageName = "requests", + PackageVersion = "2.22.0", + PackageManager = "pip", + CveName = "CVE-2024-99999", + RecommendedVersion = "2.28.0" + }, + new Vulnerability + { + Id = "PY-ok-six", + Title = "six (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 5, + ColumnNumber = 1, + FilePath = path, + PackageName = "six", + PackageVersion = "1.16.0", + PackageManager = "pip" + } + }; + } + + /// + /// Returns mock vulnerabilities for NuGet packages.config files. + /// + public static List GetPackagesConfigMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "packages.config" : filePath; + return new List + { + new Vulnerability + { + Id = "NUGET-Newtonsoft", + Title = "Newtonsoft.Json (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 4, + ColumnNumber = 4, + FilePath = path, + PackageName = "Newtonsoft.Json", + PackageVersion = "12.0.3", + PackageManager = "nuget" + } + }; + } + + /// + /// Returns mock OSS vulnerabilities for Directory.Packages.props (JetBrains MANIFEST_FILE_PATTERNS). + /// + public static List GetDirectoryPackagesPropsMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "Directory.Packages.props" : filePath; + return new List + { + new Vulnerability + { + Id = "DOTNET-Newtonsoft", + Title = "Newtonsoft.Json (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 1, + ColumnNumber = 1, + FilePath = path, + PackageName = "Newtonsoft.Json", + PackageVersion = "13.0.3", + PackageManager = "nuget" + } + }; + } + + /// + /// Returns mock OSS vulnerabilities for go.mod (JetBrains MANIFEST_FILE_PATTERNS). + /// + public static List GetGoModMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "go.mod" : filePath; + return new List + { + new Vulnerability + { + Id = "GO-golang.org-x-crypto", + Title = "golang.org/x/crypto (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 1, + ColumnNumber = 1, + FilePath = path, + PackageName = "golang.org/x/crypto", + PackageVersion = "v0.1.0", + PackageManager = "go" + } + }; + } + + /// + /// Returns mock OSS vulnerabilities for .csproj (JetBrains MANIFEST_FILE_PATTERNS). + /// + public static List GetCsprojMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "project.csproj" : filePath; + return new List + { + new Vulnerability + { + Id = "DOTNET-MSTest", + Title = "MSTest.TestFramework (OK)", + Description = "No known vulnerabilities.", + Severity = SeverityLevel.Ok, + Scanner = ScannerType.OSS, + LineNumber = 1, + ColumnNumber = 1, + FilePath = path, + PackageName = "MSTest.TestFramework", + PackageVersion = "3.0.0", + PackageManager = "nuget" + } + }; + } + + /// + /// Returns mock Container vulnerabilities for docker-compose files (JetBrains CONTAINERS_FILE_PATTERNS). + /// + public static List GetDockerComposeMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "docker-compose.yml" : filePath; + return new List + { + new Vulnerability + { + Id = "container-compose-unknown", + Title = "Compose file (Unknown)", + Description = "Container compose file – scan status unknown.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.Containers, + LineNumber = 1, + ColumnNumber = 1, + FilePath = path + }, + new Vulnerability + { + Id = "a1b2c3d4-compose-no-limits", + Title = "Memory Not Limited", + Description = "Memory limits should be defined for each service.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.Containers, + LineNumber = 4, + ColumnNumber = 5, + StartIndex = 2, + EndIndex = 12, + FilePath = path, + ExpectedValue = "'deploy.resources.limits.memory' should be defined", + ActualValue = "'deploy' is not defined" + } + }; + } + + /// + /// Returns mock IaC (KICS) vulnerabilities for Docker compose / IaC files (e.g. negative1.yaml). + /// Matches IaC realtime scan shape: ExpectedValue, ActualValue, SimilarityID, Locations. + /// + /// Optional file path; if null or empty, uses "negative1.yaml". + public static List GetIacMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "negative1.yaml" : filePath; + + return new List + { + new Vulnerability + { + Id = "c24d49e3710af1b9fa880e09c3a46afb7455000cec909ff34660f83fb56e3883", + Title = "Container Traffic Not Bound To Host Interface", + Description = "Incoming container traffic should be bound to a specific host interface", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.IaC, + LineNumber = 10, // 1-based (IaC/KICS): line 10 in file (ports) + ColumnNumber = 5, + StartIndex = 4, + EndIndex = 10, + FilePath = path, + ExpectedValue = "Docker compose file to have 'ports' attribute bound to a specific host interface.", + ActualValue = "Docker compose file doesn't have 'ports' attribute bound to a specific host interface" + }, + new Vulnerability + { + Id = "c3d88e010e72fa55d0e40eee12ad066741421c4036e1cc9f409204b38de23abd", + Title = "Healthcheck Not Set", + Description = "Check containers periodically to see if they are running properly.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.IaC, + LineNumber = 4, // 1-based (IaC/KICS): line 3 in file (services:) + ColumnNumber = 3, + StartIndex = 2, + EndIndex = 9, + FilePath = path, + ExpectedValue = "Healthcheck should be defined.", + ActualValue = "Healthcheck is not defined." + }, + new Vulnerability + { + Id = "4022c1441ba03ca00c1ad057f5e3cfb25ed165cb6b94988276bacad0485d3b74", + Title = "Memory Not Limited", + Description = "Memory limits should be defined for each container. This prevents potential resource exhaustion by ensuring that containers consume not more than the designated amount of memory", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.IaC, + LineNumber = 4, // 1-based (IaC/KICS): line 3 in file + ColumnNumber = 3, + StartIndex = 2, + EndIndex = 9, + FilePath = path, + ExpectedValue = "'deploy.resources.limits.memory' should be defined", + ActualValue = "'deploy' is not defined" + }, + new Vulnerability + { + Id = "f39f133cdd646d7f46b746af74b20062a89e3a9b6c28706ca81b40527d247656", + Title = "Security Opt Not Set", + Description = "Attribute 'security_opt' should be defined.", + Severity = SeverityLevel.Medium, + Scanner = ScannerType.IaC, + LineNumber = 4, // 1-based (IaC/KICS): line 3 in file + ColumnNumber = 3, + StartIndex = 2, + EndIndex = 9, + FilePath = path, + ExpectedValue = "Docker compose file to have 'security_opt' attribute", + ActualValue = "Docker compose file does not have 'security_opt' attribute" + }, + new Vulnerability + { + Id = "b8b8fedf4bcebf05b64d29bc81378df312516e9211063c16fca4cbc5c3a3beac", + Title = "Cpus Not Limited", + Description = "CPU limits should be set because if the system has CPU time free, a container is guaranteed to be allocated as much CPU as it requests", + Severity = SeverityLevel.Low, + Scanner = ScannerType.IaC, + LineNumber = 4, // 1-based (IaC/KICS): line 3 in file + ColumnNumber = 3, + StartIndex = 2, + EndIndex = 9, + FilePath = path, + ExpectedValue = "'deploy.resources.limits.cpus' should be defined", + ActualValue = "'deploy' is not defined" + } + }; + } + + /// + /// Returns mock Container image scan vulnerabilities for values.yaml (e.g. Helm chart referencing nginx:latest). + /// Matches AST-CLI container scan result shape: ImageName, ImageTag, FilePath, Locations (Line, StartIndex, EndIndex), Status, Vulnerabilities (CVE, Severity). + /// All CVEs share the same location (line 1, StartIndex 7, EndIndex 19) so they group in gutter/findings/Error List. + /// + /// Optional file path; if null or empty, uses "values.yaml". + public static List GetContainerImageMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "values.yaml" : filePath; + const int lineNumber = 1; // 1-based; scan had Line 0 + const int startIndex = 7; + const int endIndex = 19; + const string imageTitle = "nginx:latest"; + + var cves = new[] + { + ("CVE-2011-3374", SeverityLevel.Low), + ("TEMP-0841856-B18BAF", SeverityLevel.Unknown), + ("CVE-2022-0563", SeverityLevel.Medium), + ("CVE-2025-14104", SeverityLevel.Medium), + ("CVE-2017-18018", SeverityLevel.High), + ("CVE-2025-5278", SeverityLevel.Medium), + ("CVE-2025-10966", SeverityLevel.Medium), + ("CVE-2025-15224", SeverityLevel.Low), + ("CVE-2025-15079", SeverityLevel.Medium), + ("CVE-2025-14819", SeverityLevel.Medium), + ("CVE-2025-14524", SeverityLevel.Medium), + ("CVE-2025-14017", SeverityLevel.Medium), + ("CVE-2025-13034", SeverityLevel.Medium), + ("CVE-2018-20796", SeverityLevel.High), + ("CVE-2019-1010025", SeverityLevel.Medium), + ("CVE-2010-4756", SeverityLevel.Medium), + ("CVE-2019-9192", SeverityLevel.High), + ("CVE-2019-1010024", SeverityLevel.Medium), + ("CVE-2019-1010023", SeverityLevel.Medium), + ("CVE-2019-1010022", SeverityLevel.Critical), + ("CVE-2026-0861", SeverityLevel.High), + ("CVE-2026-0915", SeverityLevel.Unknown), + ("CVE-2025-15281", SeverityLevel.High), + ("CVE-2024-38950", SeverityLevel.Medium), + ("CVE-2024-38949", SeverityLevel.Medium), + ("CVE-2025-59375", SeverityLevel.High), + ("CVE-2025-66382", SeverityLevel.Medium), + ("CVE-2026-25210", SeverityLevel.Medium), + ("CVE-2026-24515", SeverityLevel.Low), + ("CVE-2018-6829", SeverityLevel.High), + ("CVE-2024-2236", SeverityLevel.Medium), + ("CVE-2025-14831", SeverityLevel.Medium), + ("CVE-2011-3389", SeverityLevel.Medium), + ("CVE-2018-5709", SeverityLevel.High), + ("CVE-2024-26458", SeverityLevel.Medium), + ("CVE-2024-26461", SeverityLevel.High), + ("CVE-2025-68431", SeverityLevel.High), + ("CVE-2017-17740", SeverityLevel.High), + ("CVE-2015-3276", SeverityLevel.High), + ("CVE-2017-14159", SeverityLevel.Medium), + ("CVE-2020-15719", SeverityLevel.Medium), + ("CVE-2026-22185", SeverityLevel.Medium), + ("CVE-2021-4214", SeverityLevel.Medium), + ("CVE-2025-64720", SeverityLevel.High), + ("CVE-2025-64505", SeverityLevel.Medium), + ("CVE-2025-66293", SeverityLevel.High), + ("CVE-2025-65018", SeverityLevel.High), + ("CVE-2025-64506", SeverityLevel.Medium), + ("CVE-2021-45346", SeverityLevel.Medium), + ("CVE-2025-7709", SeverityLevel.Medium), + ("CVE-2013-4392", SeverityLevel.Medium), + ("CVE-2023-31437", SeverityLevel.Medium), + ("CVE-2023-31439", SeverityLevel.Medium), + ("CVE-2023-31438", SeverityLevel.Medium), + ("CVE-2025-13151", SeverityLevel.High), + ("CVE-2025-6141", SeverityLevel.Medium), + ("CVE-2025-8732", SeverityLevel.Medium), + ("CVE-2026-1757", SeverityLevel.Medium), + ("CVE-2026-0992", SeverityLevel.Low), + ("CVE-2026-0990", SeverityLevel.Medium), + ("CVE-2026-0989", SeverityLevel.Low), + ("CVE-2024-56433", SeverityLevel.Low), + ("TEMP-0628843-DBAD28", SeverityLevel.Unknown), + ("CVE-2007-5686", SeverityLevel.Medium), + ("CVE-2026-1642", SeverityLevel.High), + ("CVE-2009-4487", SeverityLevel.Medium), + ("CVE-2013-0337", SeverityLevel.High), + ("CVE-2011-4116", SeverityLevel.Low), + ("TEMP-0517018-A83CE6", SeverityLevel.Unknown), + ("TEMP-0290435-0B57B5", SeverityLevel.Unknown), + ("CVE-2005-2541", SeverityLevel.Critical), + ("CVE-2026-3184", SeverityLevel.Medium) + }; + + var list = new List(cves.Length); + foreach (var (cve, severity) in cves) + { + list.Add(new Vulnerability + { + Id = cve, + Title = imageTitle, + Description = $"Container image vulnerability: {cve}.", + Severity = severity, + Scanner = ScannerType.Containers, + LineNumber = lineNumber, + ColumnNumber = 1, + StartIndex = startIndex, + EndIndex = endIndex, + FilePath = path, + CveName = cve + }); + } + return list; + } + + /// + /// Returns mock Container (Dockerfile) vulnerabilities. + /// Matches Container realtime scan shape: ExpectedValue, ActualValue, SimilarityID, Locations. + /// Includes Status=OK (success gutter icon) and Status=Unknown (unknown icon) per reference ContainerScanResultAdaptor getStatus(). + /// + /// Optional file path; if null or empty, uses "Dockerfile". + public static List GetContainerMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "Dockerfile" : filePath; + + return new List + { + // Container status Unknown (gutter icon only; no underline, problem window, Error List, popup) + new Vulnerability + { + Id = "container-unknown-base", + Title = "Base image (Unknown)", + Description = "Container image status unknown.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.Containers, + LineNumber = 3, + ColumnNumber = 1, + FilePath = path + }, + // Container status OK (gutter icon only) + new Vulnerability + { + Id = "container-ok-stage", + Title = "Build stage (No vulnerabilities)", + Description = "No vulnerabilities found in this stage.", + Severity = SeverityLevel.Unknown, + Scanner = ScannerType.Containers, + LineNumber = 7, + ColumnNumber = 1, + FilePath = path + }, + new Vulnerability + { + Id = "6f55673a2f4c0138b0a85c9aa5b175823a01b84aba6db7f368cfd5e4f24c563c", + Title = "Missing User Instruction", + Description = "A user should be specified in the dockerfile, otherwise the image will run as root", + Severity = SeverityLevel.High, + Scanner = ScannerType.Containers, + LineNumber = 9, + ColumnNumber = 1, + StartIndex = 0, + EndIndex = 19, + FilePath = path, + ExpectedValue = "The 'Dockerfile' should contain the 'USER' instruction", + ActualValue = "The 'Dockerfile' does not contain any 'USER' instruction" + }, + new Vulnerability + { + Id = "873ed998215f2ded3e3edadb334b918b72a6ac129df8ef95a3ce20913ed04898", + Title = "Healthcheck Instruction Missing", + Description = "Ensure that HEALTHCHECK is being used. The HEALTHCHECK instruction tells Docker how to test a container to check that it is still working", + Severity = SeverityLevel.Low, + Scanner = ScannerType.Containers, + LineNumber = 9, + ColumnNumber = 1, + StartIndex = 0, + EndIndex = 19, + FilePath = path, + ExpectedValue = "Dockerfile should contain instruction 'HEALTHCHECK'", + ActualValue = "Dockerfile doesn't contain instruction 'HEALTHCHECK'" + } + }; + } + + /// + /// Returns mock vulnerabilities for multi_findings_one_line.py (both Secrets and SAST findings). + /// Demonstrates integration of secrets detection and code analysis findings on the same file. + /// + /// Optional file path; if null or empty, uses "multi_findings_one_line.py". + public static List GetMultiFindingsOneLineMockVulnerabilities(string filePath = null) + { + var path = string.IsNullOrEmpty(filePath) ? "multi_findings_one_line.py" : filePath; + var list = new List(); + + // --- Secrets finding --- + list.Add(new Vulnerability + { + Id = "hashicorp-tf-password", + Title = "hashicorp-tf-password", + Description = "Identified a HashiCorp Terraform password field, risking unauthorized infrastructure configuration and security breaches.", + Severity = SeverityLevel.High, + Scanner = ScannerType.Secrets, + LineNumber = 1, + ColumnNumber = 27, + StartIndex = 26, + EndIndex = 46, + FilePath = path, + SecretType = "HashiCorp Terraform Password" + }); + + // --- SAST finding (deprecated cryptographic algorithm) --- + list.Add(new Vulnerability + { + Id = "SAST-4038-MD5", + Title = "Using Deprecated Cryptographic Algorithms", + Description = "Using deprecated cryptographic algorithms, such as MD5 or SHA-1, can lead to security vulnerabilities due to their susceptibility to collision and brute-force attacks. These algorithms are considered weak and may allow attackers to compromise data integrity and gain unauthorized access.", + Severity = SeverityLevel.High, + Scanner = ScannerType.ASCA, + LineNumber = 1, + ColumnNumber = 1, + FilePath = path, + RuleName = "DEPRECATED_CRYPTOGRAPHIC_ALGORITHM", + RemediationAdvice = "Consider not using deprecated or weak cryptographic algorithms such as MD5 or SHA-1." + }); + + return list; + } + + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistOutputPane.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistOutputPane.cs new file mode 100644 index 00000000..7e03f1c2 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistOutputPane.cs @@ -0,0 +1,56 @@ +using System; +using EnvDTE; +using EnvDTE80; +using Microsoft.VisualStudio.Shell; +using ast_visual_studio_extension.CxExtension.Utils; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Writes CxAssist messages to the VS Output Window ("Checkmarx" pane). + /// Same pattern as ASCAUIManager.WriteToOutputPane — main lifecycle messages only. + /// + internal static class CxAssistOutputPane + { + private static OutputWindowPane _outputPane; + private static bool _initialized; + + private static void EnsureInitialized() + { + if (_initialized) return; + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + if (dte != null) + { + var outputWindow = dte.ToolWindows.OutputWindow; + _outputPane = OutputPaneUtils.InitializeOutputPane(outputWindow, CxConstants.EXTENSION_TITLE); + } + _initialized = true; + } + catch + { + // Output pane is best-effort; swallow initialization errors + } + } + + /// + /// Writes a timestamped message to the Checkmarx Output Window pane. + /// Safe to call from UI thread only (ThreadHelper.ThrowIfNotOnUIThread inside). + /// + public static void WriteToOutputPane(string message) + { + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + EnsureInitialized(); + _outputPane?.OutputString($"{DateTime.Now}: {message}\n"); + } + catch + { + // Output pane write is best-effort + } + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistScannerConstants.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistScannerConstants.cs new file mode 100644 index 00000000..c78d4eac --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistScannerConstants.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Scanner and manifest file patterns aligned with JetBrains DevAssistConstants. + /// Used to decide which scanner applies to a file (OSS, Containers, Secrets, IAC, ASCA). + /// + internal static class CxAssistScannerConstants + { + // --- OSS: Manifest file patterns (JetBrains MANIFEST_FILE_PATTERNS) --- + // **/Directory.Packages.props, **/packages.config, **/pom.xml, **/package.json, + // **/requirements.txt, **/go.mod, **/*.csproj + public static readonly IReadOnlyList ManifestFilePatterns = new[] + { + "Directory.Packages.props", + "packages.config", + "pom.xml", + "package.json", + "requirements.txt", + "go.mod" + }; + + public static readonly string ManifestCsprojSuffix = ".csproj"; + + // --- Containers (JetBrains CONTAINERS_FILE_PATTERNS) --- + // **/dockerfile, **/dockerfile-*, **/dockerfile.*, **/docker-compose.yml, **/docker-compose.yaml, + // **/docker-compose-*.yml, **/docker-compose-*.yaml + public static readonly string DockerfileLiteral = "dockerfile"; + public static readonly string DockerComposeLiteral = "docker-compose"; + + // --- IAC (JetBrains IAC_SUPPORTED_PATTERNS + IAC_FILE_EXTENSIONS) --- + // Patterns: **/dockerfile, **/*.auto.tfvars, **/*.terraform.tfvars + // Extensions: tf, yaml, yml, json, proto, dockerfile + public static readonly IReadOnlyList IacFileExtensions = new[] + { + "tf", "yaml", "yml", "json", "proto", "dockerfile" + }; + + public static readonly string IacAutoTfvarsSuffix = ".auto.tfvars"; + public static readonly string IacTerraformTfvarsSuffix = ".terraform.tfvars"; + + // --- Helm (Containers): path contains /helm/, extension yml/yaml, exclude chart.yml, chart.yaml --- + public static readonly IReadOnlyList ContainerHelmExtensions = new[] { "yml", "yaml" }; + public static readonly IReadOnlyList ContainerHelmExcludedFiles = new[] { "chart.yml", "chart.yaml" }; + public static readonly string HelmPathSegment = "/helm/"; + + // --- Secrets: excluded paths = MANIFEST_FILE_PATTERNS + .vscode ignore files (JetBrains isExcludedFileForSecretsScanning) --- + public static readonly string CheckmarxIgnoredPathSegment1 = "/.vscode/.checkmarxIgnored"; + public static readonly string CheckmarxIgnoredPathSegment2 = "/.vscode/.checkmarxIgnoredTempList"; + public static readonly string CheckmarxIgnoredPathSegment3 = "\\.vscode\\.checkmarxIgnored"; + public static readonly string CheckmarxIgnoredPathSegment4 = "\\.vscode\\.checkmarxIgnoredTempList"; + + // --- Base (JetBrains BaseScannerService): skip node_modules --- + public static readonly string NodeModulesPathSegment = "/node_modules/"; + public static readonly string NodeModulesPathSegmentBackslash = "\\node_modules\\"; + + /// Normalizes path for pattern matching (forward slashes, lowercase where needed). + public static string NormalizePathForMatching(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return filePath; + return filePath.Replace('\\', '/'); + } + + /// Base check: file should not be under node_modules (JetBrains BaseScannerService.shouldScanFile). + public static bool PassesBaseScanCheck(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return true; + var normalized = NormalizePathForMatching(filePath); + return !normalized.Contains(NodeModulesPathSegment) && !filePath.Contains(NodeModulesPathSegmentBackslash); + } + + /// True if path matches OSS manifest patterns (JetBrains isManifestFilePatternMatching). + public static bool IsManifestFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + var normalized = NormalizePathForMatching(filePath); + var fileName = Path.GetFileName(normalized); + if (string.IsNullOrEmpty(fileName)) return false; + var fileNameLower = fileName.ToLowerInvariant(); + foreach (var pattern in ManifestFilePatterns) + { + if (fileNameLower.Equals(pattern, StringComparison.OrdinalIgnoreCase)) + return true; + } + if (fileNameLower.EndsWith(ManifestCsprojSuffix, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + /// True if path matches container file patterns: dockerfile*, docker-compose*.yml/yaml (JetBrains isContainersFilePatternMatching). + public static bool IsContainersFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + var normalized = NormalizePathForMatching(filePath).ToLowerInvariant(); + var fileName = Path.GetFileName(normalized); + if (string.IsNullOrEmpty(fileName)) return false; + if (fileName.Contains(DockerfileLiteral)) return true; + if (fileName.Contains(DockerComposeLiteral) && (fileName.EndsWith(".yml") || fileName.EndsWith(".yaml"))) + return true; + return false; + } + + /// True if file is Dockerfile (filename contains "dockerfile"). + public static bool IsDockerFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + var fileName = Path.GetFileName(filePath).ToLowerInvariant(); + return fileName.Contains(DockerfileLiteral); + } + + /// True if file is docker-compose (filename contains "docker-compose"). + public static bool IsDockerComposeFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + var fileName = Path.GetFileName(filePath).ToLowerInvariant(); + return fileName.Contains(DockerComposeLiteral); + } + + /// True if path matches IAC: dockerfile, *.auto.tfvars, *.terraform.tfvars, or extension in tf/yaml/yml/json/proto (JetBrains isIacFilePatternMatching). + public static bool IsIacFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + var normalized = NormalizePathForMatching(filePath).ToLowerInvariant(); + var fileName = Path.GetFileName(normalized); + if (fileName.Contains(DockerfileLiteral)) return true; + if (fileName.EndsWith(IacAutoTfvarsSuffix) || fileName.EndsWith(IacTerraformTfvarsSuffix)) + return true; + var ext = Path.GetExtension(normalized); + if (string.IsNullOrEmpty(ext)) return false; + ext = ext.TrimStart('.').ToLowerInvariant(); + return IacFileExtensions.Contains(ext); + } + + /// True if file is Helm chart (yaml/yml under path containing /helm/, excluding chart.yml, chart.yaml). + public static bool IsHelmFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return false; + var normalized = NormalizePathForMatching(filePath); + if (!normalized.Contains(HelmPathSegment)) return false; + var fileName = Path.GetFileName(normalized); + if (string.IsNullOrEmpty(fileName)) return false; + var lower = fileName.ToLowerInvariant(); + if (ContainerHelmExcludedFiles.Contains(lower)) return false; + var ext = Path.GetExtension(normalized); + if (string.IsNullOrEmpty(ext)) return false; + ext = ext.TrimStart('.').ToLowerInvariant(); + return ContainerHelmExtensions.Contains(ext); + } + + /// True if file is excluded from Secrets scan (manifest patterns or .vscode ignore files). + public static bool IsExcludedForSecrets(string filePath) + { + if (IsManifestFile(filePath)) return true; + var normalized = NormalizePathForMatching(filePath); + return normalized.Contains(CheckmarxIgnoredPathSegment1) || + normalized.Contains(CheckmarxIgnoredPathSegment2) || + normalized.Contains(CheckmarxIgnoredPathSegment3) || + normalized.Contains(CheckmarxIgnoredPathSegment4); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/FindingsTreeBuilder.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/FindingsTreeBuilder.cs new file mode 100644 index 00000000..4234bfae --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/FindingsTreeBuilder.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Windows.Media; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Builds the Findings window tree (FileNode → VulnerabilityNode) from any list of vulnerabilities. + /// Used for both mock data and real-time scanner results. Applies reference-style grouping + /// (IaC/ASCA by line, OSS/Secrets/Containers one per finding) and severity badges. + /// + public static class FindingsTreeBuilder + { + /// Fallback file path when a vulnerability has no FilePath (e.g. unsaved document). + public const string DefaultFilePath = "Program.cs"; + + /// + /// Converts a list of vulnerabilities into the tree model for the Findings tab. + /// + /// Findings from mock or real-time (e.g. GetCommonVulnerabilities or coordinator). + /// Callback to get severity icon. Can be null. + /// Callback to get file-type icon by file path (e.g. for VS built-in icons). Can be null. + /// Used when vulnerability.FilePath is null/empty. Defaults to DefaultFilePath. + public static ObservableCollection BuildFileNodesFromVulnerabilities( + List vulnerabilities, + Func loadSeverityIcon = null, + Func loadFileIcon = null, + string defaultFilePath = null) + { + if (vulnerabilities == null || vulnerabilities.Count == 0) + return new ObservableCollection(); + + var fallbackPath = string.IsNullOrEmpty(defaultFilePath) ? DefaultFilePath : defaultFilePath; + + // Aligned with JetBrains isProblem: show in Findings tree only for problem severities (not Ok, Unknown, Ignored). + var issuesOnly = vulnerabilities + .Where(v => CxAssistConstants.IsProblem(v.Severity)) + .ToList(); + if (issuesOnly.Count == 0) + return new ObservableCollection(); + + var grouped = issuesOnly + .GroupBy(v => string.IsNullOrEmpty(v.FilePath) ? fallbackPath : v.FilePath) + .OrderBy(g => g.Key); + + var fileNodes = new ObservableCollection(); + + foreach (var group in grouped) + { + var filePath = group.Key; + FileNode fileNode; + try + { + var fileName = Path.GetFileName(filePath); + if (string.IsNullOrEmpty(fileName)) fileName = filePath; + + var fileIcon = loadFileIcon?.Invoke(filePath); + fileNode = new FileNode + { + FileName = fileName, + FilePath = filePath, + FileIcon = fileIcon + }; + } + catch (Exception ex) + { + CxAssistOutputPane.WriteToOutputPane($"Failed to create file node for: {filePath}, {ex.Message}"); + continue; + } + + var fileVulns = group.ToList(); + var iacVulns = fileVulns.Where(v => v.Scanner == ScannerType.IaC).ToList(); + var ascaVulns = fileVulns.Where(v => v.Scanner == ScannerType.ASCA).ToList(); + var ossVulns = fileVulns.Where(v => v.Scanner == ScannerType.OSS).ToList(); + var secretsVulns = fileVulns.Where(v => v.Scanner == ScannerType.Secrets).ToList(); + var containersVulns = fileVulns.Where(v => v.Scanner == ScannerType.Containers).ToList(); + + var nodesToAdd = new List(); + + // IaC: group by line; multiple issues on same line → one row "N IAC issues detected on this line" (reference-style). + // IaC/KICS uses 1-based line numbers; use as-is for display and navigation. + foreach (var lineGroup in iacVulns.GroupBy(v => v.LineNumber)) + { + var list = lineGroup.ToList(); + var first = list[0]; + int line1Based = CxAssistConstants.To1BasedLineForDte(ScannerType.IaC, first.LineNumber); + if (list.Count > 1) + { + nodesToAdd.Add(new VulnerabilityNode + { + Severity = first.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(first.Severity.ToString()), + Description = list.Count + CxAssistConstants.MultipleIacIssuesOnLine, + Line = line1Based, + Column = first.ColumnNumber, + FilePath = first.FilePath, + Scanner = ScannerType.IaC + }); + } + else + { + nodesToAdd.Add(new VulnerabilityNode + { + Severity = first.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(first.Severity.ToString()), + Description = first.Title ?? first.Description, + Line = line1Based, + Column = first.ColumnNumber, + FilePath = first.FilePath, + Scanner = ScannerType.IaC + }); + } + } + + // ASCA: group by line; multiple on same line → "N ASCA violations detected on this line" (aligned with JetBrains ProblemDescription) + foreach (var lineGroup in ascaVulns.GroupBy(v => v.LineNumber)) + { + var list = lineGroup.ToList(); + var first = list.OrderBy(x => x.Severity).First(); + if (list.Count > 1) + { + nodesToAdd.Add(new VulnerabilityNode + { + Severity = first.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(first.Severity.ToString()), + Description = list.Count + CxAssistConstants.MultipleAscaViolationsOnLine, + Line = first.LineNumber, + Column = first.ColumnNumber, + FilePath = first.FilePath, + Scanner = ScannerType.ASCA + }); + } + else + { + nodesToAdd.Add(new VulnerabilityNode + { + Severity = first.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(first.Severity.ToString()), + Description = first.Title ?? first.Description, + Line = first.LineNumber, + Column = first.ColumnNumber, + FilePath = first.FilePath, + Scanner = ScannerType.ASCA + }); + } + } + + // OSS: group by line; multiple on same line → show highest-severity detail only (not "N OSS issues...") + foreach (var lineGroup in ossVulns.GroupBy(v => v.LineNumber)) + { + var list = lineGroup.ToList(); + var v = list.Count > 1 ? list.OrderBy(x => x.Severity).First() : list[0]; + nodesToAdd.Add(new VulnerabilityNode + { + Severity = v.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(v.Severity.ToString()), + Description = v.Title ?? v.Description, + PackageName = v.PackageName, + PackageVersion = v.PackageVersion, + Line = v.LineNumber, + Column = v.ColumnNumber, + FilePath = v.FilePath, + Scanner = ScannerType.OSS + }); + } + + // Secrets: group by line; multiple on same line → show highest-severity detail only + foreach (var lineGroup in secretsVulns.GroupBy(v => v.LineNumber)) + { + var list = lineGroup.ToList(); + var v = list.Count > 1 ? list.OrderBy(x => x.Severity).First() : list[0]; + nodesToAdd.Add(new VulnerabilityNode + { + Severity = v.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(v.Severity.ToString()), + Description = v.Title ?? v.Description, + Line = v.LineNumber, + Column = v.ColumnNumber, + FilePath = v.FilePath, + Scanner = ScannerType.Secrets + }); + } + + // Containers: group by line; multiple on same line → show highest-severity detail only + foreach (var lineGroup in containersVulns.GroupBy(v => v.LineNumber)) + { + var list = lineGroup.ToList(); + var v = list.Count > 1 ? list.OrderBy(x => x.Severity).First() : list[0]; + nodesToAdd.Add(new VulnerabilityNode + { + Severity = v.Severity.ToString(), + SeverityIcon = loadSeverityIcon?.Invoke(v.Severity.ToString()), + Description = v.Title ?? v.Description, + Line = v.LineNumber, + Column = v.ColumnNumber, + FilePath = v.FilePath, + Scanner = ScannerType.Containers + }); + } + + // Sort by line then column (reference order) + foreach (var n in nodesToAdd.OrderBy(n => n.Line).ThenBy(n => n.Column)) + fileNode.Vulnerabilities.Add(n); + + // Severity counts for badges + var severityCounts = fileNode.Vulnerabilities + .GroupBy(n => n.Severity) + .Select(g => new SeverityCount + { + Severity = g.Key, + Count = g.Count(), + Icon = loadSeverityIcon?.Invoke(g.Key) + }); + foreach (var sc in severityCounts) + fileNode.SeverityCounts.Add(sc); + + fileNodes.Add(fileNode); + } + + return fileNodes; + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphFactory.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphFactory.cs new file mode 100644 index 00000000..8736132a --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphFactory.cs @@ -0,0 +1,138 @@ +using System; +using System.ComponentModel.Composition; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Formatting; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.GutterIcons +{ + /// + /// Factory for creating custom gutter glyphs for CxAssist vulnerabilities + /// Based on reference GutterIconRenderer pattern adapted for Visual Studio MEF + /// Uses IGlyphFactory to display custom severity icons in the gutter margin + /// + internal class CxAssistGlyphFactory : IGlyphFactory + { + private const double GlyphSize = 14.0; + + public UIElement GenerateGlyph(IWpfTextViewLine line, IGlyphTag tag) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: GenerateGlyph called - tag type: {tag?.GetType().Name}"); + + if (tag == null || !(tag is CxAssistGlyphTag)) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: Tag is null or not CxAssistGlyphTag"); + return null; + } + + var glyphTag = (CxAssistGlyphTag)tag; + System.Diagnostics.Debug.WriteLine($"CxAssist: Generating glyph for severity: {glyphTag.Severity}"); + + try + { + // Create image element for the glyph (SVG preferred, fallback to PNG) + var iconSource = AssistIconLoader.LoadSeveritySvgIcon(glyphTag.Severity) + ?? (ImageSource)AssistIconLoader.LoadSeverityPngIcon(glyphTag.Severity); + if (iconSource == null) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: Icon source is null for severity: {glyphTag.Severity}"); + return null; + } + + var image = new Image + { + Width = GlyphSize, + Height = GlyphSize, + Source = iconSource + }; + + // Set tooltip with theme-appropriate background (dark in dark theme, light in light theme) + if (!string.IsNullOrEmpty(glyphTag.TooltipText)) + { + image.ToolTip = CreateThemedToolTip(glyphTag.TooltipText); + } + + System.Diagnostics.Debug.WriteLine($"CxAssist: Successfully created glyph image for severity: {glyphTag.Severity}"); + return image; + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "GlyphFactory.GenerateGlyph"); + return null; + } + } + + /// + /// Creates a tooltip with background and text colors matching the current VS theme (dark or light). + /// + private static ToolTip CreateThemedToolTip(string text) + { + bool isDark = AssistIconLoader.IsDarkTheme(); + var toolTip = new ToolTip + { + Content = new TextBlock + { + Text = text, + Foreground = isDark ? Brushes.White : new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x1E)), + Padding = new Thickness(6, 4, 6, 4) + }, + Background = isDark ? new SolidColorBrush(Color.FromRgb(0x2D, 0x2D, 0x30)) : new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)), + BorderBrush = isDark ? new SolidColorBrush(Color.FromRgb(0x3F, 0x3F, 0x46)) : new SolidColorBrush(Color.FromRgb(0xE5, 0xE5, 0xE5)), + BorderThickness = new Thickness(1), + Padding = new Thickness(0), + HasDropShadow = true + }; + return toolTip; + } + } + + /// + /// MEF export for CxAssist glyph factory provider + /// Registers the factory for the "CxAssist" glyph tag type + /// + [Export(typeof(IGlyphFactoryProvider))] + [Name("CxAssistGlyph")] + [Order(After = "VsTextMarker")] + [ContentType("code")] + [ContentType("text")] + [TagType(typeof(CxAssistGlyphTag))] + [TextViewRole(PredefinedTextViewRoles.Document)] + [TextViewRole(PredefinedTextViewRoles.Editable)] + internal sealed class CxAssistGlyphFactoryProvider : IGlyphFactoryProvider + { + public CxAssistGlyphFactoryProvider() + { + System.Diagnostics.Debug.WriteLine("CxAssist: CxAssistGlyphFactoryProvider constructor called - MEF is loading glyph factory provider"); + } + + public IGlyphFactory GetGlyphFactory(IWpfTextView view, IWpfTextViewMargin margin) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: GetGlyphFactory called for margin: {margin?.GetType().Name}"); + return new CxAssistGlyphFactory(); + } + } + + /// + /// Custom glyph tag for CxAssist vulnerabilities + /// Based on reference GutterIconRenderer pattern + /// + internal class CxAssistGlyphTag : IGlyphTag + { + public string Severity { get; } + public string TooltipText { get; } + public string VulnerabilityId { get; } + + public CxAssistGlyphTag(string severity, string tooltipText, string vulnerabilityId) + { + Severity = severity; + TooltipText = tooltipText; + VulnerabilityId = vulnerabilityId; + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTagger.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTagger.cs new file mode 100644 index 00000000..d110aa2e --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTagger.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.GutterIcons +{ + /// + /// Tagger that provides glyph tags for CxAssist vulnerabilities + /// Based on reference MarkupModel.addRangeHighlighter pattern + /// Manages the lifecycle of gutter icons in the text view + /// + internal class CxAssistGlyphTagger : ITagger + { + private readonly ITextBuffer _buffer; + private readonly Dictionary> _vulnerabilitiesByLine; + + public event EventHandler TagsChanged; + + public CxAssistGlyphTagger(ITextBuffer buffer) + { + _buffer = buffer; + _vulnerabilitiesByLine = new Dictionary>(); + } + + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + var result = new List>(); + + if (spans == null || spans.Count == 0 || _vulnerabilitiesByLine.Count == 0) + return result; + + ITextSnapshot snapshot = null; + try + { + snapshot = spans[0].Snapshot; + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "GlyphTagger.GetTags (snapshot)"); + } + + if (snapshot == null) return result; + + foreach (var span in spans) + { + try + { + var startLine = snapshot.GetLineNumberFromPosition(span.Start); + var endLine = snapshot.GetLineNumberFromPosition(span.End); + + for (int lineNumber = startLine; lineNumber <= endLine; lineNumber++) + { + if (_vulnerabilitiesByLine.TryGetValue(lineNumber, out var vulnerabilities)) + { + var line = snapshot.GetLineFromLineNumber(lineNumber); + + // Skip empty/whitespace-only lines (aligned with JetBrains getPsiElement validation) + if (!HasNonWhitespaceContent(snapshot, line)) + continue; + + var mostSevere = GetMostSevereVulnerability(vulnerabilities); + if (mostSevere != null) + { + var lineSpan = new SnapshotSpan(snapshot, line.Start, line.Length); + var tag = new CxAssistGlyphTag( + mostSevere.Severity.ToString(), + mostSevere.Severity.ToString(), + mostSevere.Id + ); + result.Add(new TagSpan(lineSpan, tag)); + } + } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "GlyphTagger.GetTags (span)"); + } + } + + return result; + } + + /// + /// Updates vulnerabilities for the buffer + /// Based on reference ProblemDecorator.decorateUI pattern + /// + public void UpdateVulnerabilities(List vulnerabilities) + { + CxAssistErrorHandler.TryRun(() => UpdateVulnerabilitiesCore(vulnerabilities), "GlyphTagger.UpdateVulnerabilities"); + } + + private void UpdateVulnerabilitiesCore(List vulnerabilities) + { + _vulnerabilitiesByLine.Clear(); + + var snapshot = _buffer.CurrentSnapshot; + if (vulnerabilities != null) + { + int lineCount = snapshot.LineCount; + foreach (var vuln in vulnerabilities) + { + // Gutter on first line only: use first location's line when Locations is set, else LineNumber. + int gutterLine1Based = (vuln.Locations != null && vuln.Locations.Count > 0) + ? vuln.Locations[0].Line + : vuln.LineNumber; + if (!CxAssistConstants.IsLineInRange(gutterLine1Based, lineCount)) + continue; + int lineNumber = CxAssistConstants.To0BasedLineForEditor(vuln.Scanner, gutterLine1Based); + if (!_vulnerabilitiesByLine.ContainsKey(lineNumber)) + _vulnerabilitiesByLine[lineNumber] = new List(); + _vulnerabilitiesByLine[lineNumber].Add(vuln); + } + } + + var entireSpan = new SnapshotSpan(snapshot, 0, snapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(entireSpan)); + } + + /// + /// Clears all vulnerabilities + /// Based on reference ProblemDecorator.removeAllHighlighters pattern + /// + public void ClearVulnerabilities() + { + _vulnerabilitiesByLine.Clear(); + + var snapshot = _buffer.CurrentSnapshot; + var entireSpan = new SnapshotSpan(snapshot, 0, snapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(entireSpan)); + } + + /// + /// Whether the line has at least one non-whitespace character (aligned with JetBrains getPsiElement: + /// skip lines with no code content to avoid placing gutter icons on empty lines). + /// + private static bool HasNonWhitespaceContent(ITextSnapshot snapshot, ITextSnapshotLine line) + { + int start = line.Start.Position; + int end = line.End.Position; + for (int i = start; i < end; i++) + { + if (!char.IsWhiteSpace(snapshot[i])) + return true; + } + return false; + } + + /// + /// Gets the most severe vulnerability from a list + /// Based on reference ProblemDecorator.getMostSeverity pattern + /// + private Vulnerability GetMostSevereVulnerability(List vulnerabilities) + { + if (vulnerabilities == null || vulnerabilities.Count == 0) + return null; + + try + { + return vulnerabilities + .OrderByDescending(v => GetSeverityPriority(v.Severity)) + .FirstOrDefault(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] Exception retrieving most severe vulnerability: {ex.Message}"); + return vulnerabilities.FirstOrDefault(); + } + } + + /// + /// Gets severity priority for ordering (higher number = more severe) + /// Based on reference SeverityLevel precedence (inverted for descending order) + /// + private int GetSeverityPriority(SeverityLevel severity) + { + switch (severity) + { + case SeverityLevel.Malicious: return 8; // Highest priority + case SeverityLevel.Critical: return 7; + case SeverityLevel.High: return 6; + case SeverityLevel.Medium: return 5; + case SeverityLevel.Low: return 4; + case SeverityLevel.Unknown: return 3; + case SeverityLevel.Ok: return 2; + case SeverityLevel.Ignored: return 1; + case SeverityLevel.Info: return 1; + default: return 0; + } + } + + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTaggerProvider.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTaggerProvider.cs new file mode 100644 index 00000000..db396beb --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTaggerProvider.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.GutterIcons +{ + /// + /// MEF provider for CxAssist glyph tagger + /// Based on reference EditorFactoryListener pattern adapted for Visual Studio + /// Creates and manages tagger instances per buffer (not per view) + /// IMPORTANT: Uses ITaggerProvider (not IViewTaggerProvider) for glyph tags + /// + [Export(typeof(ITaggerProvider))] + [ContentType("code")] + [ContentType("text")] + [TagType(typeof(CxAssistGlyphTag))] + [TextViewRole(PredefinedTextViewRoles.Document)] + [TextViewRole(PredefinedTextViewRoles.Editable)] + internal class CxAssistGlyphTaggerProvider : ITaggerProvider + { + // Static instance for external access + private static CxAssistGlyphTaggerProvider _instance; + + // Cache taggers per buffer to ensure single instance per buffer + private readonly Dictionary _taggers = + new Dictionary(); + + public CxAssistGlyphTaggerProvider() + { + System.Diagnostics.Debug.WriteLine("CxAssist: CxAssistGlyphTaggerProvider constructor called - MEF is loading this provider"); + _instance = this; + } + + public ITagger CreateTagger(ITextBuffer buffer) where T : ITag + { + System.Diagnostics.Debug.WriteLine($"CxAssist: CreateTagger called - buffer: {buffer != null}"); + + if (buffer == null) + return null; + + // Return existing tagger or create new one + lock (_taggers) + { + if (!_taggers.TryGetValue(buffer, out var tagger)) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: CreateTagger - creating NEW tagger for buffer"); + tagger = new CxAssistGlyphTagger(buffer); + _taggers[buffer] = tagger; + + // Store tagger in buffer properties for external access + try + { + buffer.Properties.AddProperty(typeof(CxAssistGlyphTagger), tagger); + System.Diagnostics.Debug.WriteLine($"CxAssist: CreateTagger - tagger stored in buffer properties"); + } + catch + { + System.Diagnostics.Debug.WriteLine($"CxAssist: CreateTagger - tagger already in buffer properties"); + } + + // Clean up when buffer is closed + buffer.Properties.GetOrCreateSingletonProperty(() => new BufferClosedListener(buffer, () => + { + lock (_taggers) + { + _taggers.Remove(buffer); + buffer.Properties.RemoveProperty(typeof(CxAssistGlyphTagger)); + } + })); + } + else + { + System.Diagnostics.Debug.WriteLine($"CxAssist: CreateTagger - returning EXISTING tagger"); + } + + return tagger as ITagger; + } + } + + /// + /// Gets the tagger for a specific buffer (for external access) + /// Allows CxAssistPOC or other components to update vulnerabilities + /// IMPORTANT: Only returns taggers created by MEF through CreateTagger() + /// This ensures Visual Studio is properly subscribed to TagsChanged events + /// + public static CxAssistGlyphTagger GetTaggerForBuffer(ITextBuffer buffer) + { + if (buffer == null) + { + System.Diagnostics.Debug.WriteLine("CxAssist: GetTaggerForBuffer - buffer is null"); + return null; + } + + // ONLY get tagger from buffer properties - do NOT create it directly + // The tagger MUST be created by MEF through CreateTagger() so that + // Visual Studio subscribes to the TagsChanged event + if (buffer.Properties.TryGetProperty(typeof(CxAssistGlyphTagger), out CxAssistGlyphTagger tagger)) + { + System.Diagnostics.Debug.WriteLine("CxAssist: GetTaggerForBuffer - found tagger in buffer properties"); + return tagger; + } + + // Also check instance cache + if (_instance != null) + { + lock (_instance._taggers) + { + if (_instance._taggers.TryGetValue(buffer, out tagger)) + { + System.Diagnostics.Debug.WriteLine("CxAssist: GetTaggerForBuffer - found tagger in instance cache"); + return tagger; + } + } + } + + System.Diagnostics.Debug.WriteLine("CxAssist: GetTaggerForBuffer - tagger NOT found (MEF hasn't created it yet)"); + return null; + } + + /// + /// Finds the ITextBuffer for a given file path among tracked buffers (for theme-change re-trigger). + /// Returns null if no buffer is tracked for that path. + /// + public static ITextBuffer GetBufferForFile(string filePath) + { + if (string.IsNullOrEmpty(filePath) || _instance == null) return null; + lock (_instance._taggers) + { + foreach (var buffer in _instance._taggers.Keys) + { + string bufferPath = CxAssistDisplayCoordinator.GetFilePathForBuffer(buffer); + if (!string.IsNullOrEmpty(bufferPath) && + string.Equals(bufferPath, filePath, StringComparison.OrdinalIgnoreCase)) + return buffer; + } + } + return null; + } + + /// + /// Helper class to clean up taggers when buffer is closed + /// + private class BufferClosedListener + { + private readonly ITextBuffer _buffer; + private readonly Action _onClosed; + + public BufferClosedListener(ITextBuffer buffer, Action onClosed) + { + _buffer = buffer; + _onClosed = onClosed; + _buffer.Changed += OnBufferChanged; + } + + private void OnBufferChanged(object sender, TextContentChangedEventArgs e) + { + // Check if buffer is being disposed + if (_buffer.Properties.ContainsProperty("BufferClosed")) + { + _buffer.Changed -= OnBufferChanged; + _onClosed?.Invoke(); + } + } + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistMockDataViewCreationListener.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistMockDataViewCreationListener.cs new file mode 100644 index 00000000..2aea530b --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistMockDataViewCreationListener.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Threading; +using EnvDTE; +using EnvDTE80; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.GutterIcons +{ + /// + /// When a file matching scanner manifest/container/IAC/Secrets patterns is opened, loads the corresponding + /// mock data and updates gutter, underline, problem window, Error List, and popup. + /// Logic aligned with JetBrains: MANIFEST_FILE_PATTERNS (OSS), CONTAINERS_FILE_PATTERNS + Helm (Containers), + /// IAC_SUPPORTED_PATTERNS + IAC_FILE_EXTENSIONS (IAC), and Secrets exclusions. + /// + [Export(typeof(IWpfTextViewCreationListener))] + [ContentType("code")] + [ContentType("text")] + [TextViewRole(PredefinedTextViewRoles.Document)] + internal class CxAssistMockDataViewCreationListener : IWpfTextViewCreationListener + { + private static bool IsCSharpFile(string filePath) + { + return !string.IsNullOrEmpty(filePath) && filePath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns mock vulnerabilities for the file based on JetBrains-aligned scanner logic: + /// Base: skip node_modules. OSS: manifest files only. Containers: dockerfile*, docker-compose* + Helm. + /// IAC: dockerfile, *.tfvars, or extension tf/yaml/yml/json/proto. Secrets: non-manifest (e.g. secrets.py). + /// + private static List GetMockVulnerabilitiesForFile(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return null; + + // Base check (JetBrains BaseScannerService.shouldScanFile) + if (!CxAssistScannerConstants.PassesBaseScanCheck(filePath)) + return null; + + string fileName = Path.GetFileName(filePath); + if (string.IsNullOrEmpty(fileName)) return null; + + var pathNormalized = CxAssistScannerConstants.NormalizePathForMatching(filePath); + var fileNameLower = fileName.ToLowerInvariant(); + + // --- OSS: only manifest files (JetBrains OssScannerService.isManifestFilePatternMatching) --- + if (CxAssistScannerConstants.IsManifestFile(filePath)) + { + if (fileName.Equals("package.json", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetPackageJsonMockVulnerabilities(filePath); + if (fileName.EndsWith("pom.xml", StringComparison.OrdinalIgnoreCase) || fileNameLower.EndsWith(".pom", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetPomMockVulnerabilities(filePath); + if (fileName.Equals("build.gradle", StringComparison.OrdinalIgnoreCase) || fileName.Equals("build.gradle.kts", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetBuildGradleMockVulnerabilities(filePath); + if (fileName.Equals("requirements.txt", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("Pipfile", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("pyproject.toml", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetRequirementsMockVulnerabilities(filePath); + if (fileName.Equals("packages.config", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetPackagesConfigMockVulnerabilities(filePath); + if (fileName.Equals("package-lock.json", StringComparison.OrdinalIgnoreCase) || fileName.Equals("yarn.lock", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetPackageJsonMockVulnerabilities(filePath); + if (fileName.Equals("Directory.Packages.props", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetDirectoryPackagesPropsMockVulnerabilities(filePath); + if (fileName.Equals("go.mod", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetGoModMockVulnerabilities(filePath); + if (fileNameLower.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetCsprojMockVulnerabilities(filePath); + return null; + } + + // --- Containers: dockerfile*, docker-compose* (JetBrains ContainerScannerService) or Helm / values.yaml --- + if (CxAssistScannerConstants.IsHelmFile(filePath) || + fileName.Equals("values.yaml", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("values.yml", StringComparison.OrdinalIgnoreCase)) + { + var iac = CxAssistMockData.GetIacMockVulnerabilities(filePath); + var containerImage = CxAssistMockData.GetContainerImageMockVulnerabilities(filePath); + var merged = new List(iac.Count + containerImage.Count); + merged.AddRange(iac); + merged.AddRange(containerImage); + return merged; + } + if (CxAssistScannerConstants.IsContainersFile(filePath)) + { + if (CxAssistScannerConstants.IsDockerFile(filePath)) + return CxAssistMockData.GetContainerMockVulnerabilities(filePath); + if (CxAssistScannerConstants.IsDockerComposeFile(filePath)) + return CxAssistMockData.GetDockerComposeMockVulnerabilities(filePath); + return CxAssistMockData.GetContainerMockVulnerabilities(filePath); + } + + // --- IAC: tf, yaml, yml, json, proto, dockerfile, *.auto.tfvars, *.terraform.tfvars (JetBrains IacScannerService) --- + if (CxAssistScannerConstants.IsIacFile(filePath)) + return CxAssistMockData.GetIacMockVulnerabilities(filePath); + + // --- Secrets: scan non-manifest files; we only have mock for a specific secrets file (JetBrains: exclude manifest + .vscode) --- + if (!CxAssistScannerConstants.IsExcludedForSecrets(filePath)) + { + if (fileName.Equals("secrets.py", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetSecretsPyMockVulnerabilities(filePath); + + if (fileName.StartsWith("multi_findings_one_line", StringComparison.OrdinalIgnoreCase) && + fileName.EndsWith(".py", StringComparison.OrdinalIgnoreCase)) + return CxAssistMockData.GetMultiFindingsOneLineMockVulnerabilities(filePath); + } + + return null; + } + + public void TextViewCreated(IWpfTextView textView) + { + CxAssistDisplayCoordinator.EnsureThemeChangeHandler(); + + string filePath = null; + try + { + filePath = CxAssistDisplayCoordinator.GetFilePathForBuffer(textView.TextBuffer); + if (string.IsNullOrEmpty(filePath)) + { + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + filePath = dte?.ActiveDocument?.FullName; + } + } + catch { } + + if (IsCSharpFile(filePath)) + return; + + // Skip Copilot/AI assistant temporary files (aligned with JetBrains DevAssistInspection.isAgentEvent) + if (CxAssistConstants.IsAIAgentFile(filePath)) + { + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.AI_AGENT_FILE_SKIPPING, filePath)); + return; + } + + // Skip when no scanner is enabled (aligned with JetBrains DevAssistFileListener.restoreGutterIcons) + if (!CxAssistConstants.IsAnyScannerEnabled()) + { + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.NO_SCANNER_ENABLED_SKIPPING, filePath)); + return; + } + + // Restore cached findings if this file was previously scanned (JetBrains: DevAssistFileListener.restoreGutterIcons) + List cachedVulnerabilities = CxAssistDisplayCoordinator.GetCachedVulnerabilitiesForFile(filePath); + + // Fall back to mock data when no cached findings exist + List vulnerabilities = cachedVulnerabilities ?? GetMockVulnerabilitiesForFile(filePath); + if (vulnerabilities == null || vulnerabilities.Count == 0) + return; + + var vulnsToApply = vulnerabilities; + System.Threading.Tasks.Task.Delay(1000).ContinueWith(_ => + { + try + { + ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var buffer = textView.TextBuffer; + filePath = CxAssistDisplayCoordinator.GetFilePathForBuffer(buffer); + if (string.IsNullOrEmpty(filePath)) + { + try + { + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + filePath = dte?.ActiveDocument?.FullName ?? "file"; + } + catch { filePath = "file"; } + } + + CxAssistGlyphTagger glyphTagger = null; + CxAssistErrorTagger errorTagger = null; + for (int i = 0; i < 8; i++) + { + glyphTagger = CxAssistGlyphTaggerProvider.GetTaggerForBuffer(buffer); + errorTagger = CxAssistErrorTaggerProvider.GetTaggerForBuffer(buffer); + if (glyphTagger != null && errorTagger != null) break; + await System.Threading.Tasks.Task.Delay(200); + } + + if (glyphTagger == null || errorTagger == null) + return; + + // Re-check cached findings (may have been updated while waiting for taggers) + var latestCached = CxAssistDisplayCoordinator.GetCachedVulnerabilitiesForFile(filePath); + var finalVulns = latestCached ?? GetMockVulnerabilitiesForFile(filePath); + if (finalVulns == null || finalVulns.Count == 0) + return; + + CxAssistDisplayCoordinator.UpdateFindings(buffer, finalVulns, filePath); + CxAssistOutputPane.WriteToOutputPane(string.Format(CxAssistConstants.UI_DECORATED_SUCCESSFULLY, filePath, finalVulns.Count)); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] Exception restoring gutter icons for: {filePath}, {ex.Message}"); + } + }); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistTextViewCreationListener.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistTextViewCreationListener.cs new file mode 100644 index 00000000..ba422d21 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistTextViewCreationListener.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Threading; +using EnvDTE; +using EnvDTE80; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.GutterIcons +{ + /// + /// Listens for text view creation and automatically adds test gutter icons and colored markers + /// This is a temporary POC to test gutter icon and marker functionality + /// + [Export(typeof(IWpfTextViewCreationListener))] + [ContentType("CSharp")] + [TextViewRole(PredefinedTextViewRoles.Document)] + internal class CxAssistTextViewCreationListener : IWpfTextViewCreationListener + { + private static int _fallbackDocumentCounter; + + public void TextViewCreated(IWpfTextView textView) + { + System.Diagnostics.Debug.WriteLine("CxAssist: TextViewCreated - C# file opened"); + + // Wait for MEF to create the taggers, then add test vulnerabilities + // We need to wait because the taggers are created asynchronously by MEF + System.Threading.Tasks.Task.Delay(1000).ContinueWith(_ => + { + try + { + Microsoft.VisualStudio.Shell.ThreadHelper.JoinableTaskFactory.Run(async () => + { + await Microsoft.VisualStudio.Shell.ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + System.Diagnostics.Debug.WriteLine("CxAssist: Attempting to add test vulnerabilities to C# file"); + + var buffer = textView.TextBuffer; + + // Try to get the glyph tagger - it should have been created by MEF by now + CxAssistGlyphTagger glyphTagger = null; + CxAssistErrorTagger errorTagger = null; + + // Try multiple times with delays in case MEF is still loading + for (int i = 0; i < 8; i++) + { + glyphTagger = CxAssistGlyphTaggerProvider.GetTaggerForBuffer(buffer); + errorTagger = CxAssistErrorTaggerProvider.GetTaggerForBuffer(buffer); + + if (glyphTagger != null && errorTagger != null) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: Both taggers found on attempt {i + 1}"); + break; + } + System.Diagnostics.Debug.WriteLine($"CxAssist: Taggers not found, attempt {i + 1}/8, waiting..."); + await System.Threading.Tasks.Task.Delay(200); + } + + if (glyphTagger != null && errorTagger != null) + { + System.Diagnostics.Debug.WriteLine("CxAssist: Both taggers found, updating via coordinator (gutter, underline, problem window)"); + + // Single coordinator call: updates gutter, underline, and current findings for problem window (Option B) + var filePath = CxAssistDisplayCoordinator.GetFilePathForBuffer(buffer); + // When path is unknown (e.g. ITextDocument not available), try active document so problem window shows real file name + if (string.IsNullOrEmpty(filePath)) + { + try + { + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + if (!string.IsNullOrEmpty(dte?.ActiveDocument?.FullName)) + filePath = dte.ActiveDocument.FullName; + } + catch { } + if (string.IsNullOrEmpty(filePath)) + { + var fallback = Interlocked.Increment(ref _fallbackDocumentCounter); + filePath = $"Document {fallback}"; + System.Diagnostics.Debug.WriteLine($"CxAssist: GetFilePathForBuffer returned null, using fallback: {filePath}"); + } + } + var vulnerabilities = CxAssistMockData.GetCommonVulnerabilities(filePath); + CxAssistDisplayCoordinator.UpdateFindings(buffer, vulnerabilities, filePath); + + System.Diagnostics.Debug.WriteLine("CxAssist: Coordinator updated gutter, underline, and findings successfully"); + } + else + { + System.Diagnostics.Debug.WriteLine("CxAssist: Taggers are NULL - MEF hasn't created them yet"); + } + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: Error adding test vulnerabilities: {ex.Message}"); + } + }); + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistAsyncQuickInfoSource.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistAsyncQuickInfoSource.cs new file mode 100644 index 00000000..52734025 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistAsyncQuickInfoSource.cs @@ -0,0 +1,122 @@ +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Async Quick Info source so the modern presenter can wire navigation callbacks + /// (legacy IQuickInfoSource presenter ignores per-ClassifiedTextRun actions). + /// Only one content block is shown per session even when multiple subject buffers exist. + /// + internal class CxAssistAsyncQuickInfoSource : IAsyncQuickInfoSource + { + private static readonly HashSet _sessionsWithCxAssistContent = new HashSet(); + private static readonly object _sessionLock = new object(); + + private readonly ITextBuffer _buffer; + private bool _disposed; + + public CxAssistAsyncQuickInfoSource(ITextBuffer buffer) + { + _buffer = buffer; + } + + public async Task GetQuickInfoItemAsync(IAsyncQuickInfoSession session, CancellationToken cancellationToken) + { + SnapshotPoint? triggerPoint = session.GetTriggerPoint(_buffer.CurrentSnapshot); + if (!triggerPoint.HasValue && session.TextView != null) + { + var viewSnapshot = session.TextView.TextSnapshot; + var viewTrigger = session.GetTriggerPoint(viewSnapshot); + if (viewTrigger.HasValue && viewTrigger.Value.Snapshot.TextBuffer != _buffer) + { + var mapped = session.TextView.BufferGraph.MapDownToFirstMatch( + viewTrigger.Value, + PointTrackingMode.Positive, + sb => sb == _buffer, + PositionAffinity.Predecessor); + if (mapped.HasValue) + triggerPoint = mapped.Value; + } + else if (viewTrigger.HasValue && viewTrigger.Value.Snapshot.TextBuffer == _buffer) + { + triggerPoint = viewTrigger; + } + } + + if (!triggerPoint.HasValue) + return null; + + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + + var snapshot = triggerPoint.Value.Snapshot; + int lineNumber = snapshot.GetLineNumberFromPosition(triggerPoint.Value.Position); + + var tagger = CxAssistErrorTaggerProvider.GetTaggerForBuffer(_buffer); + if (tagger == null) + return null; + + var vulnerabilities = tagger.GetVulnerabilitiesForLine(lineNumber); + if (vulnerabilities == null || vulnerabilities.Count == 0) + return null; + + // Success (Ok) and Unknown: gutter icon only; do not show in popup + var issuesOnly = vulnerabilities + .Where(v => v.Severity != SeverityLevel.Ok && v.Severity != SeverityLevel.Unknown) + .ToList(); + if (issuesOnly.Count == 0) + return null; + + // Only one of our sources (per session) should contribute; avoid duplicate blocks when multiple subject buffers exist. + lock (_sessionLock) + { + if (_sessionsWithCxAssistContent.Contains(session)) + return null; + _sessionsWithCxAssistContent.Add(session); + } + + void OnSessionStateChanged(object sender, QuickInfoSessionStateChangedEventArgs e) + { + if (e.NewState == QuickInfoSessionState.Dismissed) + { + lock (_sessionLock) + { + _sessionsWithCxAssistContent.Remove(session); + } + session.StateChanged -= OnSessionStateChanged; + } + } + + session.StateChanged += OnSessionStateChanged; + + object content = CxAssistQuickInfoSource.BuildQuickInfoContentForLine(issuesOnly); + if (content == null) + { + lock (_sessionLock) + { + _sessionsWithCxAssistContent.Remove(session); + } + session.StateChanged -= OnSessionStateChanged; + return null; + } + + var line = snapshot.GetLineFromLineNumber(lineNumber); + var applicableToSpan = snapshot.CreateTrackingSpan(line.Extent, SpanTrackingMode.EdgeInclusive); + return new QuickInfoItem(applicableToSpan, content); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistAsyncQuickInfoSourceProvider.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistAsyncQuickInfoSourceProvider.cs new file mode 100644 index 00000000..7555d2d0 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistAsyncQuickInfoSourceProvider.cs @@ -0,0 +1,20 @@ +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Utilities; +using System.ComponentModel.Composition; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + [Export(typeof(IAsyncQuickInfoSourceProvider))] + [Name("CxAssist Async QuickInfo Source")] + [Order(Before = "Default Quick Info Presenter")] + [ContentType("code")] + [ContentType("text")] + internal class CxAssistAsyncQuickInfoSourceProvider : IAsyncQuickInfoSourceProvider + { + public IAsyncQuickInfoSource TryCreateQuickInfoSource(ITextBuffer textBuffer) + { + return new CxAssistAsyncQuickInfoSource(textBuffer); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTagger.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTagger.cs new file mode 100644 index 00000000..f807e357 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTagger.cs @@ -0,0 +1,260 @@ +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Tagging; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Tagger that provides IErrorTag for CxAssist vulnerabilities. + /// Uses VS built-in ErrorTag only; no custom tag. VS draws squiggles and shows tooltip. + /// + internal class CxAssistErrorTagger : ITagger + { + private readonly ITextBuffer _buffer; + private readonly Dictionary> _vulnerabilitiesByLine; + + public event EventHandler TagsChanged; + + public CxAssistErrorTagger(ITextBuffer buffer) + { + _buffer = buffer; + _vulnerabilitiesByLine = new Dictionary>(); + } + + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + var result = new List>(); + + if (spans == null || spans.Count == 0 || _vulnerabilitiesByLine.Count == 0) + return result; + + ITextSnapshot snapshot = null; + try + { + snapshot = spans[0].Snapshot; + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "ErrorTagger.GetTags (snapshot)"); + } + + if (snapshot == null) return result; + foreach (var span in spans) + { + try + { + var startLine = snapshot.GetLineNumberFromPosition(span.Start); + var endLine = snapshot.GetLineNumberFromPosition(span.End); + + for (int lineNumber = startLine; lineNumber <= endLine; lineNumber++) + { + if (_vulnerabilitiesByLine.TryGetValue(lineNumber, out var vulnerabilities)) + { + foreach (var vulnerability in vulnerabilities) + { + try + { + if (!ShouldShowUnderline(vulnerability.Severity)) + continue; + + var line = snapshot.GetLineFromLineNumber(lineNumber); + SnapshotSpan underlineSpan = GetUnderlineSpan(snapshot, line, vulnerability); + + var tooltipText = BuildTooltipText(vulnerability); + IErrorTag tag = new ErrorTag("Error", tooltipText); + result.Add(new TagSpan(underlineSpan, tag)); + } + catch (Exception innerEx) + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] Exception highlighting line {lineNumber}: {innerEx.Message}"); + } + } + } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "ErrorTagger.GetTags (span)"); + } + } + + return result; + } + + /// + /// Gets the snapshot span for the underline. When Locations is set, use the range for this line from the matching location. + /// Otherwise: on the first line use StartIndex/EndIndex when set; on continuation lines use full line. + /// Full-line fallback trims leading/trailing whitespace so the squiggle covers only code characters + /// (aligned with JetBrains DevAssistUtils.getTextRangeForLine). + /// + private static SnapshotSpan GetUnderlineSpan(ITextSnapshot snapshot, ITextSnapshotLine line, Vulnerability v) + { + int line0Based = line.LineNumber; + int line1Based = line0Based + 1; + + // Per-line locations (e.g. pom.xml): use StartIndex/EndIndex for this line when present. + if (v.Locations != null && v.Locations.Count > 0) + { + foreach (var loc in v.Locations) + { + if (loc.Line != line1Based) continue; + if (loc.EndIndex > loc.StartIndex && loc.StartIndex >= 0) + { + int startOffset = Math.Min(loc.StartIndex, line.Length); + int length = Math.Min(loc.EndIndex - loc.StartIndex, line.Length - startOffset); + if (length > 0) + { + int startPos = line.Start + startOffset; + return new SnapshotSpan(snapshot, startPos, length); + } + } + return GetTrimmedLineSpan(snapshot, line); + } + return GetTrimmedLineSpan(snapshot, line); + } + + // Fallback: single LineNumber/EndLineNumber with one StartIndex/EndIndex on first line. + int firstLine0Based = CxAssistConstants.To0BasedLineForEditor(v.Scanner, v.LineNumber); + bool isFirstLine = (line0Based == firstLine0Based); + if (isFirstLine && v.EndIndex > v.StartIndex && v.StartIndex >= 0) + { + int startOffset = Math.Min(v.StartIndex, line.Length); + int length = Math.Min(v.EndIndex - v.StartIndex, line.Length - startOffset); + if (length > 0) + { + int startPos = line.Start + startOffset; + return new SnapshotSpan(snapshot, startPos, length); + } + } + return GetTrimmedLineSpan(snapshot, line); + } + + /// + /// Returns a SnapshotSpan covering the line text with leading and trailing whitespace trimmed. + /// Falls back to the full line if the line is all whitespace. + /// + private static SnapshotSpan GetTrimmedLineSpan(ITextSnapshot snapshot, ITextSnapshotLine line) + { + int lineStart = line.Start.Position; + int lineEnd = line.End.Position; + + int trimmedStart = lineStart; + while (trimmedStart < lineEnd && char.IsWhiteSpace(snapshot[trimmedStart])) + trimmedStart++; + + int trimmedEnd = lineEnd; + while (trimmedEnd > trimmedStart && char.IsWhiteSpace(snapshot[trimmedEnd - 1])) + trimmedEnd--; + + if (trimmedStart >= trimmedEnd) + return new SnapshotSpan(snapshot, lineStart, line.Length); + + return new SnapshotSpan(snapshot, trimmedStart, trimmedEnd - trimmedStart); + } + + /// + /// Determines if a severity level should show an underline (squiggle). + /// Aligned with JetBrains ScanIssueProcessor: underline only when isProblem(severity) is true + /// (not Ok, not Unknown, not Ignored). Gutter icons are shown for all severities. + /// + private static bool ShouldShowUnderline(SeverityLevel severity) + { + return CxAssistConstants.IsProblem(severity); + } + + /// + /// Builds tooltip text for ErrorTag. Use minimal text so the rich Quick Info (async source) is the single place + /// for full content; avoids duplicate "Checkmarx One Assist" block from ErrorTag tooltip in the same popup. + /// + private static string BuildTooltipText(Vulnerability vulnerability) + { + return null; + } + + /// + /// Updates the vulnerabilities and triggers a refresh of error tags + /// Similar to reference MarkupModel.removeAllHighlighters() + addRangeHighlighter() + /// + public void UpdateVulnerabilities(List vulnerabilities) + { + CxAssistErrorHandler.TryRun(() => UpdateVulnerabilitiesCore(vulnerabilities), "ErrorTagger.UpdateVulnerabilities"); + } + + private void UpdateVulnerabilitiesCore(List vulnerabilities) + { + _vulnerabilitiesByLine.Clear(); + + var snapshot = _buffer.CurrentSnapshot; + if (vulnerabilities != null) + { + int lineCount = snapshot.LineCount; + foreach (var vulnerability in vulnerabilities) + { + // Per-line locations (e.g. pom.xml): add this vulnerability to each line in Locations; LineNumber = first location for gutter. + if (vulnerability.Locations != null && vulnerability.Locations.Count > 0) + { + foreach (var loc in vulnerability.Locations) + { + if (!CxAssistConstants.IsLineInRange(loc.Line, lineCount)) + continue; + int lineNumber = CxAssistConstants.To0BasedLineForEditor(vulnerability.Scanner, loc.Line); + if (!_vulnerabilitiesByLine.ContainsKey(lineNumber)) + _vulnerabilitiesByLine[lineNumber] = new List(); + _vulnerabilitiesByLine[lineNumber].Add(vulnerability); + } + continue; + } + // Fallback: LineNumber..EndLineNumber range. + if (!CxAssistConstants.IsLineInRange(vulnerability.LineNumber, lineCount)) + continue; + int lastUnderlineLine = GetUnderlineEndLine(vulnerability); + for (int line1Based = vulnerability.LineNumber; line1Based <= lastUnderlineLine; line1Based++) + { + if (!CxAssistConstants.IsLineInRange(line1Based, lineCount)) + break; + int lineNumber = CxAssistConstants.To0BasedLineForEditor(vulnerability.Scanner, line1Based); + if (!_vulnerabilitiesByLine.ContainsKey(lineNumber)) + _vulnerabilitiesByLine[lineNumber] = new List(); + _vulnerabilitiesByLine[lineNumber].Add(vulnerability); + } + } + } + + var entireSpan = new SnapshotSpan(snapshot, 0, snapshot.Length); + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(entireSpan)); + } + + /// Last 1-based line for underline (multi-line block). When EndLineNumber is set, use it; otherwise single line. + private static int GetUnderlineEndLine(Vulnerability v) + { + if (v.EndLineNumber > 0 && v.EndLineNumber >= v.LineNumber) + return v.EndLineNumber; + return v.LineNumber; + } + + /// + /// Clears all vulnerabilities and error tags + /// Similar to reference MarkupModel.removeAllHighlighters() + /// + public void ClearVulnerabilities() + { + UpdateVulnerabilities(null); + } + + /// + /// Gets vulnerabilities on the given line (0-based) for rich Quick Info hover. + /// + public IReadOnlyList GetVulnerabilitiesForLine(int zeroBasedLineNumber) + { + return CxAssistErrorHandler.TryGet( + () => _vulnerabilitiesByLine.TryGetValue(zeroBasedLineNumber, out var list) ? list : (IReadOnlyList)Array.Empty(), + "ErrorTagger.GetVulnerabilitiesForLine", + Array.Empty()); + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTaggerProvider.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTaggerProvider.cs new file mode 100644 index 00000000..0589e3bf --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTaggerProvider.cs @@ -0,0 +1,104 @@ +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using System.Collections.Generic; +using System.ComponentModel.Composition; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// MEF provider for CxAssist error tagger + /// Based on reference EditorFactoryListener pattern adapted for Visual Studio + /// Creates and manages error tagger instances per buffer (not per view). + /// Exports IErrorTag so VS built-in error layer draws squiggles using IErrorType (CompilerError / syntax error colour). + /// + [Export(typeof(ITaggerProvider))] + [ContentType("code")] + [ContentType("text")] + [TagType(typeof(IErrorTag))] + [TextViewRole(PredefinedTextViewRoles.Document)] + [TextViewRole(PredefinedTextViewRoles.Editable)] + internal class CxAssistErrorTaggerProvider : ITaggerProvider + { + // Static instance for external access + private static CxAssistErrorTaggerProvider _instance; + + // Cache taggers per buffer to ensure single instance per buffer + private readonly Dictionary _taggers = + new Dictionary(); + + public CxAssistErrorTaggerProvider() + { + System.Diagnostics.Debug.WriteLine("CxAssist Markers: CxAssistErrorTaggerProvider constructor called - MEF is loading error tagger provider"); + _instance = this; + } + + public ITagger CreateTagger(ITextBuffer buffer) where T : ITag + { + System.Diagnostics.Debug.WriteLine($"CxAssist Markers: CreateTagger called - buffer: {buffer != null}"); + + if (buffer == null) + return null; + + // Return cached tagger if it exists, otherwise create new one + lock (_taggers) + { + if (_taggers.TryGetValue(buffer, out var existingTagger)) + { + System.Diagnostics.Debug.WriteLine("CxAssist Markers: Returning existing error tagger from cache"); + return existingTagger as ITagger; + } + + System.Diagnostics.Debug.WriteLine("CxAssist Markers: Creating new error tagger"); + var tagger = new CxAssistErrorTagger(buffer); + _taggers[buffer] = tagger; + + // Clean up when buffer is disposed + buffer.Properties.GetOrCreateSingletonProperty(() => + { + buffer.Changed += (sender, args) => + { + // Could add buffer change handling here if needed + }; + return tagger; + }); + + return tagger as ITagger; + } + } + + /// + /// Gets the error tagger for a specific buffer + /// Used by external components to update vulnerability markers + /// Similar to reference MarkupModel access pattern + /// + public static CxAssistErrorTagger GetTaggerForBuffer(ITextBuffer buffer) + { + if (_instance == null || buffer == null) + return null; + + lock (_instance._taggers) + { + _instance._taggers.TryGetValue(buffer, out var tagger); + return tagger; + } + } + + /// + /// Gets all active error taggers + /// Useful for debugging and diagnostics + /// + public static IEnumerable GetAllTaggers() + { + if (_instance == null) + return new List(); + + lock (_instance._taggers) + { + return new List(_instance._taggers.Values); + } + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickFixActions.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickFixActions.cs new file mode 100644 index 00000000..b77e852d --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickFixActions.cs @@ -0,0 +1,245 @@ +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using Microsoft.VisualStudio.Imaging.Interop; +using Microsoft.VisualStudio.Language.Intellisense; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Quick Fix action: "Fix with Checkmarx One Assist" (same behavior as hover popup link). + /// + internal sealed class FixWithCxOneAssistSuggestedAction : ISuggestedAction + { + private readonly Vulnerability _vulnerability; + + public FixWithCxOneAssistSuggestedAction(Vulnerability vulnerability) + { + _vulnerability = vulnerability ?? throw new ArgumentNullException(nameof(vulnerability)); + + } + + public string DisplayText => "Fix with Checkmarx One Assist"; + + public string IconAutomationText => null; + + public ImageMoniker IconMoniker => default(ImageMoniker); + + public string InputGestureText => null; + + public bool HasPreview => false; + + public bool HasActionSets => false; + + public void Dispose() + { + } + + public Task> GetActionSetsAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(null); + } + + public Task GetPreviewAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public void Invoke(CancellationToken cancellationToken) + { + if (_vulnerability == null) return; + var v = _vulnerability; + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => + { + try + { + CxAssistCopilotActions.SendFixWithAssist(v); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "FixWithCxOneAssistSuggestedAction.Invoke"); + } + })); + } + + public bool TryGetTelemetryId(out Guid telemetryId) + { + telemetryId = Guid.Empty; + return false; + } + } + + /// + /// Quick Fix action: "View details" (same behavior as hover popup link). + /// + internal sealed class ViewDetailsSuggestedAction : ISuggestedAction + { + private readonly Vulnerability _vulnerability; + + public ViewDetailsSuggestedAction(Vulnerability vulnerability) + { + _vulnerability = vulnerability ?? throw new ArgumentNullException(nameof(vulnerability)); + } + + public string DisplayText => "View details"; + + public string IconAutomationText => null; + + public ImageMoniker IconMoniker => default(ImageMoniker); + + public string InputGestureText => null; + + public bool HasPreview => false; + + public bool HasActionSets => false; + + public void Dispose() + { + } + + public Task> GetActionSetsAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(null); + } + + public Task GetPreviewAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + + public void Invoke(CancellationToken cancellationToken) + { + if (_vulnerability == null) return; + var v = _vulnerability; + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => + { + try + { + CxAssistCopilotActions.SendViewDetails(v); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "ViewDetailsSuggestedAction.Invoke"); + } + })); + } + + public bool TryGetTelemetryId(out Guid telemetryId) + { + telemetryId = Guid.Empty; + return false; + } + } + + /// + /// Quick Fix action: "Ignore this vulnerability" (same as hover popup / context menu). + /// + internal sealed class IgnoreThisVulnerabilitySuggestedAction : ISuggestedAction + { + private readonly Vulnerability _vulnerability; + + public IgnoreThisVulnerabilitySuggestedAction(Vulnerability vulnerability) + { + _vulnerability = vulnerability ?? throw new ArgumentNullException(nameof(vulnerability)); + } + + public string DisplayText => CxAssistConstants.GetIgnoreThisLabel(_vulnerability.Scanner); + + public string IconAutomationText => null; + + public ImageMoniker IconMoniker => default(ImageMoniker); + + public string InputGestureText => null; + + public bool HasPreview => false; + + public bool HasActionSets => false; + + public void Dispose() { } + + public Task> GetActionSetsAsync(CancellationToken cancellationToken) + => Task.FromResult>(null); + + public Task GetPreviewAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + public void Invoke(CancellationToken cancellationToken) + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => + { + try + { + MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "IgnoreThisVulnerabilitySuggestedAction.Invoke"); + } + })); + } + + public bool TryGetTelemetryId(out Guid telemetryId) + { + telemetryId = Guid.Empty; + return false; + } + } + + /// + /// Quick Fix action: "Ignore all of this type" (same as hover popup / context menu). + /// + internal sealed class IgnoreAllOfThisTypeSuggestedAction : ISuggestedAction + { + private readonly Vulnerability _vulnerability; + + public IgnoreAllOfThisTypeSuggestedAction(Vulnerability vulnerability) + { + _vulnerability = vulnerability ?? throw new ArgumentNullException(nameof(vulnerability)); + } + + public string DisplayText => CxAssistConstants.GetIgnoreAllLabel(_vulnerability.Scanner); + + public string IconAutomationText => null; + + public ImageMoniker IconMoniker => default(ImageMoniker); + + public string InputGestureText => null; + + public bool HasPreview => false; + + public bool HasActionSets => false; + + public void Dispose() { } + + public Task> GetActionSetsAsync(CancellationToken cancellationToken) + => Task.FromResult>(null); + + public Task GetPreviewAsync(CancellationToken cancellationToken) + => Task.FromResult(null); + + public void Invoke(CancellationToken cancellationToken) + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => + { + try + { + MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "IgnoreAllOfThisTypeSuggestedAction.Invoke"); + } + })); + } + + public bool TryGetTelemetryId(out Guid telemetryId) + { + telemetryId = Guid.Empty; + return false; + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoController.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoController.cs new file mode 100644 index 00000000..c0ad0043 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoController.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Triggers the default Quick Info popup on mouse hover over lines with CxAssist vulnerabilities. + /// Content is provided by CxAssistAsyncQuickInfoSource (no custom popup). + /// + internal class CxAssistQuickInfoController : IIntellisenseController + { + private readonly ITextView _textView; + private readonly IList _subjectBuffers; + private readonly CxAssistQuickInfoControllerProvider _provider; + + internal CxAssistQuickInfoController( + ITextView textView, + IList subjectBuffers, + CxAssistQuickInfoControllerProvider provider) + { + _textView = textView; + _subjectBuffers = subjectBuffers; + _provider = provider; + _textView.MouseHover += OnTextViewMouseHover; + } + + private void OnTextViewMouseHover(object sender, MouseHoverEventArgs e) + { + try + { + var point = _textView.BufferGraph.MapDownToFirstMatch( + new SnapshotPoint(_textView.TextSnapshot, e.Position), + PointTrackingMode.Positive, + snapshot => _subjectBuffers.Contains(snapshot.TextBuffer), + PositionAffinity.Predecessor); + + if (!point.HasValue) + return; + + var buffer = point.Value.Snapshot.TextBuffer; + int lineNumber = point.Value.Snapshot.GetLineNumberFromPosition(point.Value.Position); + + var tagger = CxAssistErrorTaggerProvider.GetTaggerForBuffer(buffer); + if (tagger == null) + return; + + var vulnerabilities = tagger.GetVulnerabilitiesForLine(lineNumber); + if (vulnerabilities == null || vulnerabilities.Count == 0) + return; + + if (!_provider.AsyncQuickInfoBroker.IsQuickInfoActive(_textView)) + { + var triggerPoint = point.Value.Snapshot.CreateTrackingPoint(point.Value.Position, PointTrackingMode.Positive); + _ = _provider.AsyncQuickInfoBroker.TriggerQuickInfoAsync(_textView, triggerPoint, QuickInfoSessionOptions.None, CancellationToken.None); + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfoController.OnTextViewMouseHover"); + } + } + + public void Detach(ITextView textView) + { + if (_textView == textView) + _textView.MouseHover -= OnTextViewMouseHover; + } + + public void ConnectSubjectBuffer(ITextBuffer subjectBuffer) { } + + public void DisconnectSubjectBuffer(ITextBuffer subjectBuffer) { } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoControllerProvider.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoControllerProvider.cs new file mode 100644 index 00000000..6004d86a --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoControllerProvider.cs @@ -0,0 +1,24 @@ +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; +using System.Collections.Generic; +using System.ComponentModel.Composition; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + [Export(typeof(IIntellisenseControllerProvider))] + [Name("CxAssist QuickInfo Controller")] + [ContentType("code")] + [ContentType("text")] + internal class CxAssistQuickInfoControllerProvider : IIntellisenseControllerProvider + { + [Import] + internal IAsyncQuickInfoBroker AsyncQuickInfoBroker { get; set; } + + public IIntellisenseController TryCreateIntellisenseController(ITextView textView, IList subjectBuffers) + { + return new CxAssistQuickInfoController(textView, subjectBuffers, this); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoSource.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoSource.cs new file mode 100644 index 00000000..613b64bb --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoSource.cs @@ -0,0 +1,1009 @@ +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Language.StandardClassification; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Text.Adornments; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Static helper for building Quick Info content (ContainerElement, ClassifiedTextElement, ClassifiedTextRun). + /// Used by (IAsyncQuickInfoSource); legacy IQuickInfoSource was removed. + /// + internal static class CxAssistQuickInfoSource + { + internal const bool UseRichHover = true; + + /// + /// Builds Quick Info content for all vulnerabilities on the line (reference-style: grouped by scanner, engine-specific layout). + /// Single vuln: one scanner block. Multiple same scanner: OSS/Containers show severity counts; ASCA/IAC show per-vuln rows. Multiple scanners: one section per scanner. + /// + internal static object BuildQuickInfoContentForLine(IReadOnlyList vulnerabilities) + { + if (vulnerabilities == null || vulnerabilities.Count == 0) + return null; + if (vulnerabilities.Count == 1) + return BuildQuickInfoContent(vulnerabilities[0]); + + var elements = new List(); + AddHeaderRow(elements); + + var byScanner = vulnerabilities + .GroupBy(v => v.Scanner) + .OrderBy(g => g.Key.ToString()) + .ToList(); + + for (int i = 0; i < byScanner.Count; i++) + { + if (i > 0) + { + var sep = CreateHorizontalSeparator(); + if (sep != null) elements.Add(sep); + } + BuildContentForScannerGroup(byScanner[i].Key, byScanner[i].ToList(), elements); + } + + var separator = CreateHorizontalSeparator(); + if (separator != null) elements.Add(separator); + return new ContainerElement(ContainerElementStyle.Stacked, elements); + } + + /// + /// Builds content for a single vulnerability (reference-style: one scanner block). + /// + internal static object BuildQuickInfoContent(Vulnerability v) + { + if (v == null) return null; + var elements = new List(); + AddHeaderRow(elements); + BuildContentForScannerGroup(v.Scanner, new List { v }, elements); + var separator = CreateHorizontalSeparator(); + if (separator != null) elements.Add(separator); + return new ContainerElement(ContainerElementStyle.Stacked, elements); + } + + /// + /// Appends DevAssist header row to elements. + /// + private static void AddHeaderRow(List elements) + { + var headerRow = CreateHeaderRow(); + if (headerRow != null) + elements.Add(headerRow); + else + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, CxAssistConstants.DisplayName, ClassifiedTextRunStyle.UseClassificationStyle | ClassifiedTextRunStyle.UseClassificationFont) + )); + } + + /// + /// reference-style: one block per scanner type. OSS/Containers = header + severity counts + remediation. Secrets = severity + title + "Secret finding". ASCA/IAC = per-vuln rows with remediation each. + /// + private static void BuildContentForScannerGroup(ScannerType scanner, List vulns, List elements) + { + if (vulns == null || vulns.Count == 0) return; + + switch (scanner) + { + case ScannerType.OSS: + BuildOssDescription(vulns, elements); + break; + case ScannerType.Containers: + BuildContainerDescription(vulns, elements); + break; + case ScannerType.Secrets: + BuildSecretsDescription(vulns, elements); + break; + case ScannerType.ASCA: + BuildAscaDescription(vulns, elements); + break; + case ScannerType.IaC: + BuildIacDescription(vulns, elements); + break; + default: + BuildDefaultDescription(vulns, elements); + break; + } + } + + /// OSS: package header (title@version + highest severity + "Severity Package", reference-style) + severity count badges (e.g. H 1, M 1) + remediation (with Ignore all of this type). + private static void BuildOssDescription(List vulns, List elements) + { + var first = vulns[0]; + var title = string.IsNullOrEmpty(first.PackageName) ? (first.Title ?? first.Description ?? "") : first.PackageName; + var version = first.PackageVersion ?? ""; + // Use highest severity among all vulns for header (e.g. validator with 1 High + 1 Medium → "High Severity Package") + var headerSeverity = GetHighestSeverity(vulns); + var severityLabel = headerSeverity == SeverityLevel.Malicious ? "Malicious package" : (CxAssistConstants.GetRichSeverityName(headerSeverity) + " " + CxAssistConstants.SeverityPackageLabel); + var displayTitle = string.IsNullOrEmpty(version) ? title : $"{title}@{version}"; + // reference: package row uses neutral package/cube icon (not severity icon); Malicious keeps severity icon; severity label greyed out + var packageTitleRow = headerSeverity == SeverityLevel.Malicious + ? CreateSeverityTitleRow(headerSeverity, $"{displayTitle} - {severityLabel}", severityLabel) + : CreateOssPackageTitleRow(displayTitle, severityLabel); + if (packageTitleRow != null) elements.Add(packageTitleRow); + else + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, severityLabel, ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " " + title + (string.IsNullOrEmpty(version) ? "" : "@" + version), ClassifiedTextRunStyle.UseClassificationFont) + )); + // Reference plugin: count row only for Critical/High/Medium/Low; do not show count for Malicious-only + if (vulns.Any(v => v.Severity != SeverityLevel.Malicious)) + BuildSeverityCountSection(vulns, elements); + var linksRow = CreateActionLinksRow(first, includeIgnoreAllOfThisType: true); + if (linksRow != null) elements.Add(linksRow); + else AddDefaultActionLinks(first, elements, includeIgnoreAll: true); + } + + /// Containers: container icon + "imageName:tag - Critical Severity Image" (JetBrains-style); fallback to severity icon if no container icon. + private static void BuildContainerDescription(List vulns, List elements) + { + var first = vulns[0]; + var title = first.Title ?? first.PackageName ?? first.Description ?? "Container image"; + var tag = first.PackageVersion ?? ""; + var headerText = string.IsNullOrEmpty(tag) ? title : $"{title}@{tag}"; + var headerSeverity = GetHighestSeverity(vulns); + var severityLabel = headerSeverity == SeverityLevel.Malicious + ? "Malicious image" + : (CxAssistConstants.GetRichSeverityName(headerSeverity) + " " + CxAssistConstants.SeverityImageLabel); + // Prefer container icon (neutral) + text, like OSS package row; fallback to severity icon if no container icon + var row = CreateContainerTitleRow(headerText, severityLabel); + if (row == null) + { + var displayTitle = $"{headerText} - {severityLabel}"; + row = CreateSeverityTitleRow(headerSeverity, displayTitle, severityLabel); + } + if (row != null) elements.Add(row); + else + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, severityLabel, ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " " + headerText, ClassifiedTextRunStyle.UseClassificationFont) + )); + if (vulns.Any(v => v.Severity != SeverityLevel.Malicious)) + BuildSeverityCountSection(vulns, elements); + var linksRow = CreateActionLinksRow(first, includeIgnoreAllOfThisType: true); + if (linksRow != null) elements.Add(linksRow); + else AddDefaultActionLinks(first, elements, includeIgnoreAll: true); + } + + /// Returns the highest severity present in the list (for OSS package header: e.g. High when vulns are High + Medium). + private static SeverityLevel GetHighestSeverity(List vulns) + { + if (vulns == null || vulns.Count == 0) return SeverityLevel.Unknown; + var order = new[] { SeverityLevel.Malicious, SeverityLevel.Critical, SeverityLevel.High, SeverityLevel.Medium, SeverityLevel.Low, SeverityLevel.Info, SeverityLevel.Unknown, SeverityLevel.Ok, SeverityLevel.Ignored }; + var set = vulns.Select(x => x.Severity).ToHashSet(); + return order.FirstOrDefault(s => set.Contains(s)); + } + + /// Secrets: severity icon + bold title (Title-Case) + grey " - Secret finding" + three actions (reference-style). + private static void BuildSecretsDescription(List vulns, List elements) + { + var v = vulns[0]; + var rawTitle = v.Title ?? v.RuleName ?? v.Description ?? ""; + var displayTitle = CxAssistConstants.FormatSecretTitle(rawTitle); + var secretRow = CreateSecretFindingTitleRow(v.Severity, displayTitle); + if (secretRow != null) elements.Add(secretRow); + else + { + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, CxAssistConstants.GetRichSeverityName(v.Severity), ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " " + displayTitle + " - " + CxAssistConstants.SecretFindingLabel, ClassifiedTextRunStyle.UseClassificationFont) + )); + } + var linksRow = CreateActionLinksRow(v, includeIgnoreAllOfThisType: false); + if (linksRow != null) elements.Add(linksRow); + else AddDefaultActionLinks(v, elements, includeIgnoreAll: false); + } + + /// ASCA: reference-style — summary line when multiple; per-vuln row (icon + bold title - description - grey "SAST vulnerability"); separators between entries. + private static void BuildAscaDescription(List vulns, List elements) + { + if (vulns == null || vulns.Count == 0) return; + + if (vulns.Count > 1) + { + var summaryRow = CreateMultipleIssuesSummaryRow(vulns.Count, CxAssistConstants.MultipleAscaViolationsOnLine); + if (summaryRow != null) elements.Add(summaryRow); + } + + for (int i = 0; i < vulns.Count; i++) + { + var v = vulns[i]; + var title = v.Title ?? v.RuleName ?? v.Description ?? ""; + var desc = v.Description ?? "Vulnerability detected by ASCA."; + var ascaRow = CreateAscaTitleRow(v.Severity, title, desc); + if (ascaRow != null) elements.Add(ascaRow); + else + { + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, CxAssistConstants.GetRichSeverityName(v.Severity), ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " " + title + " - " + desc + " - " + CxAssistConstants.SastVulnerabilityLabel, ClassifiedTextRunStyle.UseClassificationFont) + )); + } + var linksRow = CreateActionLinksRow(v, includeIgnoreAllOfThisType: false); + if (linksRow != null) elements.Add(linksRow); + else AddDefaultActionLinks(v, elements, includeIgnoreAll: false); + if (i < vulns.Count - 1) + { + var sep = CreateHorizontalSeparator(); + if (sep != null) elements.Add(sep); + } + } + } + + /// IaC: reference-style — summary line when multiple; per-vuln row (icon + bold title - actualValue description - grey "IaC vulnerability"); separators between entries. + private static void BuildIacDescription(List vulns, List elements) + { + if (vulns == null || vulns.Count == 0) return; + + // Summary line for multiple issues (reference: "4 IAC issues detected on this line Checkmarx One Assist") + if (vulns.Count > 1) + { + var summaryRow = CreateMultipleIssuesSummaryRow(vulns.Count, CxAssistConstants.MultipleIacIssuesOnLine); + if (summaryRow != null) elements.Add(summaryRow); + } + + for (int i = 0; i < vulns.Count; i++) + { + var v = vulns[i]; + var title = v.Title ?? v.RuleName ?? v.Description ?? ""; + var actualVal = v.ActualValue ?? ""; + var desc = v.Description ?? "IaC finding."; + var iacRow = CreateIacTitleRow(v.Severity, title, actualVal, desc); + if (iacRow != null) elements.Add(iacRow); + else + { + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, CxAssistConstants.GetRichSeverityName(v.Severity), ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " " + title + (string.IsNullOrEmpty(actualVal) ? "" : " - " + actualVal) + " " + desc + " " + CxAssistConstants.IacVulnerabilityLabel, ClassifiedTextRunStyle.UseClassificationFont) + )); + } + var linksRow = CreateActionLinksRow(v, includeIgnoreAllOfThisType: false); + if (linksRow != null) elements.Add(linksRow); + else AddDefaultActionLinks(v, elements, includeIgnoreAll: false); + // Separator between entries (reference: thin grey line between each finding) + if (i < vulns.Count - 1) + { + var sep = CreateHorizontalSeparator(); + if (sep != null) elements.Add(sep); + } + } + } + + /// Default: severity + title + description + remediation. + private static void BuildDefaultDescription(List vulns, List elements) + { + var v = vulns[0]; + var title = v.Title ?? v.RuleName ?? v.Description ?? ""; + var description = v.Description ?? "Vulnerability detected by " + v.Scanner + "."; + var severityTitleRow = CreateSeverityTitleRow(v.Severity, title, CxAssistConstants.GetRichSeverityName(v.Severity)); + if (severityTitleRow != null) elements.Add(severityTitleRow); + else + { + elements.Add(new ClassifiedTextElement( + new ClassifiedTextRun(PredefinedClassificationTypeNames.Keyword, CxAssistConstants.GetRichSeverityName(v.Severity), ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " " + title, ClassifiedTextRunStyle.UseClassificationFont) + )); + } + var descBlock = CreateDescriptionBlock(description); + if (descBlock != null) elements.Add(descBlock); + else elements.Add(new ClassifiedTextElement(new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, description, ClassifiedTextRunStyle.UseClassificationFont))); + var linksRow = CreateActionLinksRow(v, includeIgnoreAllOfThisType: false); + if (linksRow != null) elements.Add(linksRow); + else AddDefaultActionLinks(v, elements, includeIgnoreAll: false); + } + + /// Severity count row: icon + bold count for each severity. Never show count for Malicious package (reference plugin has no Malicious count icon). + private static void BuildSeverityCountSection(List vulns, List elements) + { + if (vulns == null || vulns.Count == 0) return; + // Do not show count row when all findings are Malicious + if (vulns.All(v => v.Severity == SeverityLevel.Malicious)) return; + + var counts = vulns.GroupBy(x => x.Severity).ToDictionary(g => g.Key, g => g.Count()); + // Only Critical, High, Medium, Low, Info—never Malicious + var order = new[] { SeverityLevel.Critical, SeverityLevel.High, SeverityLevel.Medium, SeverityLevel.Low, SeverityLevel.Info }; + + try + { + var panel = ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var stack = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center + }; + GetQuickInfoTextBrushes(out _, out var countBrush); + foreach (var sev in order) + if (counts.TryGetValue(sev, out var c) && c > 0) + { + var icon = CreateSmallSeverityIcon(sev); + if (icon != null) stack.Children.Add(icon); + stack.Children.Add(new TextBlock + { + Text = c.ToString(), + FontSize = 10, + FontWeight = FontWeights.Bold, + Foreground = countBrush, + Margin = new Thickness(2, 0, 8, 0), + VerticalAlignment = VerticalAlignment.Center + }); + } + if (stack.Children.Count == 0) return null; + var border = new Border + { + Child = stack, + MinHeight = 18, + Height = 18, + Margin = new Thickness(0, 2, 0, 4), + VerticalAlignment = VerticalAlignment.Top, + Padding = new Thickness(0) + }; + return (System.Windows.UIElement)border; + }); + if (panel != null) + elements.Add(panel); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.BuildSeverityCountSection"); + } + } + + private static System.Windows.UIElement CreateSmallSeverityIcon(SeverityLevel severity) + { + var source = AssistIconLoader.LoadSeverityIcon(severity); + if (source == null) return null; + return new Image { Source = source, Width = 14, Height = 14, Stretch = Stretch.Uniform, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 2, 0) }; + } + + private static void AddDefaultActionLinks(Vulnerability v, List elements, bool includeIgnoreAll) + { + const string urlClassification = "url"; + string ignoreThisLabel = CxAssistConstants.GetIgnoreThisLabel(v.Scanner); + var runs = new List + { + new ClassifiedTextRun(urlClassification, CxAssistConstants.FixWithCxOneAssist, () => RunFixWithAssist(v), CxAssistConstants.FixWithCxOneAssist, ClassifiedTextRunStyle.Underline), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " ", ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(urlClassification, CxAssistConstants.ViewDetails, () => RunViewDetails(v), CxAssistConstants.ViewDetails, ClassifiedTextRunStyle.Underline), + new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " ", ClassifiedTextRunStyle.UseClassificationFont), + new ClassifiedTextRun(urlClassification, ignoreThisLabel, () => RunIgnoreVulnerability(v), ignoreThisLabel, ClassifiedTextRunStyle.Underline) + }; + if (includeIgnoreAll) + { + runs.Add(new ClassifiedTextRun(PredefinedClassificationTypeNames.Text, " ", ClassifiedTextRunStyle.UseClassificationFont)); + runs.Add(new ClassifiedTextRun(urlClassification, CxAssistConstants.IgnoreAllOfThisType, () => RunIgnoreAllOfThisType(v), CxAssistConstants.IgnoreAllOfThisType, ClassifiedTextRunStyle.Underline)); + } + elements.Add(new ClassifiedTextElement(runs.ToArray())); + } + + internal static void RunIgnoreAllOfThisType(Vulnerability v) + { + RunOnUiThread(() => MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information)); + } + + internal static void RunFixWithAssist(Vulnerability v) + { + RunOnUiThread(() => + { + // For IaC/ASCA from popup: send ONLY this vulnerability, not all on the line + // Pass empty list to prevent auto-resolve in SendFixWithAssist + var sameLineVulns = (v.Scanner == Models.ScannerType.IaC || v.Scanner == Models.ScannerType.ASCA) + ? new List { v } // Only this one + : null; // Other scanners: null (auto-resolve handled in SendFixWithAssist) + CxAssistCopilotActions.SendFixWithAssist(v, sameLineVulns); + }); + } + + internal static void RunViewDetails(Vulnerability v) + { + RunOnUiThread(() => + { + // For IaC/ASCA from popup: send ONLY this vulnerability, not all on the line + // Pass list with only this vulnerability to prevent auto-resolve in SendViewDetails + var relatedVulns = (v.Scanner == Models.ScannerType.IaC || v.Scanner == Models.ScannerType.ASCA) + ? new List { v } // Only this one + : null; // OSS: null (auto-resolve for same package handled in SendViewDetails) + CxAssistCopilotActions.SendViewDetails(v, relatedVulns); + }); + } + + internal static void RunIgnoreVulnerability(Vulnerability v) + { + RunOnUiThread(() => MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information)); + } + + internal static void RunOnUiThread(Action action) + { + if (action == null) return; + try + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.Invoke(action, DispatcherPriority.Send); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.RunOnUiThread"); + } + } + + /// + /// Description text with extra line spacing between lines. + /// + private static System.Windows.UIElement CreateDescriptionBlock(string description) + { + if (string.IsNullOrEmpty(description)) return null; + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + GetQuickInfoTextBrushes(out var descBrush, out _); + return new TextBlock + { + Text = description, + TextWrapping = TextWrapping.Wrap, + LineHeight = 20, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight, + Foreground = descBrush, + FontSize = 12, + Margin = new Thickness(0, 0, 0, 6) + }; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateDescriptionBlock"); + return null; + } + } + + /// + /// Action links row: Fix with Checkmarx Assist, View Details, Ignore vulnerability; for OSS/Containers also "Ignore all of this type" (reference-style). + /// + private static System.Windows.UIElement CreateActionLinksRow(Vulnerability v, bool includeIgnoreAllOfThisType = false) + { + if (v == null) return null; + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var linkBrush = new SolidColorBrush(Color.FromRgb(0x56, 0x9C, 0xD6)); + var panel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 6, 0, 0) }; + + void AddLink(string text, Action clickAction) + { + var block = new TextBlock + { + Text = text, + Foreground = linkBrush, + Cursor = System.Windows.Input.Cursors.Hand, + Margin = new Thickness(0, 0, 12, 0) + }; + block.MouseEnter += (s, _) => { block.TextDecorations = TextDecorations.Underline; }; + block.MouseLeave += (s, _) => { block.TextDecorations = null; }; + block.MouseLeftButtonDown += (s, _) => { RunOnUiThread(clickAction); }; + panel.Children.Add(block); + } + + AddLink(CxAssistConstants.FixWithCxOneAssist, () => RunFixWithAssist(v)); + AddLink(CxAssistConstants.ViewDetails, () => RunViewDetails(v)); + AddLink(CxAssistConstants.GetIgnoreThisLabel(v.Scanner), () => RunIgnoreVulnerability(v)); + if (includeIgnoreAllOfThisType) + AddLink(CxAssistConstants.IgnoreAllOfThisType, () => RunIgnoreAllOfThisType(v)); + + return (System.Windows.UIElement)panel; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateActionLinksRow"); + return null; + } + } + + /// + /// Summary row when multiple issues on same line (reference: "4 IAC issues detected on this line Checkmarx One Assist" with suffix grey). + /// + private static System.Windows.UIElement CreateMultipleIssuesSummaryRow(int count, string suffix) + { + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + GetQuickInfoTextBrushes(out var brightBrush, out var greyBrush); + var text = new TextBlock + { + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + LineHeight = 18, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight, + Margin = new Thickness(0, 0, 0, 6) + }; + text.Inlines.Add(new Run(count + suffix) { Foreground = brightBrush }); + text.Inlines.Add(new Run(" " + CxAssistConstants.DisplayName) { Foreground = greyBrush }); + return (System.Windows.UIElement)text; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateMultipleIssuesSummaryRow"); + return null; + } + } + + /// Theme-aware primary (title/main) and secondary (grey suffix) text brushes for Quick Info. Call from UI thread. + private static void GetQuickInfoTextBrushes(out System.Windows.Media.Brush primary, out System.Windows.Media.Brush secondary) + { + bool isDark = AssistIconLoader.IsDarkTheme(); + if (isDark) + { + primary = new SolidColorBrush(Color.FromRgb(0xF0, 0xF0, 0xF0)); + secondary = new SolidColorBrush(Color.FromRgb(0xAD, 0xAD, 0xAD)); + } + else + { + primary = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x1E)); + secondary = new SolidColorBrush(Color.FromRgb(0x6B, 0x6B, 0x6B)); + } + } + + /// + /// Creates a thin horizontal line (separator) to show after our Quick Info details. + /// Bottom margin adds gap between this line and VS's "Show potential fixes" below. + /// + private static System.Windows.UIElement CreateHorizontalSeparator() + { + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + return new Border + { + Height = 0, + Margin = new Thickness(0, 10, 0, 10), + Background = Brushes.Transparent + }; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateHorizontalSeparator"); + return null; + } + } + + /// + /// Header row: badge + "Checkmarx One Assist" text (custom-popup style, no custom popup). + /// + private static System.Windows.UIElement CreateHeaderRow() + { + var source = AssistIconLoader.LoadBadgeIcon(); + if (source == null) + return null; + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var image = new Image + { + Source = source, + Width = 150, + Height = 32, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0) + }; + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Margin = new Thickness(0, 0, 0, 6) + }; + panel.Children.Add(image); + return (System.Windows.UIElement)panel; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateHeaderRow"); + return null; + } + } + + /// + /// Container image title row: neutral container icon + image:tag (bold) + " - " + severity label (greyed), JetBrains-style. + /// + private static System.Windows.UIElement CreateContainerTitleRow(string displayTitle, string severityLabel) + { + var containerSource = AssistIconLoader.LoadContainerIcon(); + if (containerSource == null) return null; + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + var image = new Image + { + Source = containerSource, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(image, 0); + grid.Children.Add(image); + const double fontSize = 12; + GetQuickInfoTextBrushes(out var brightBrush, out var greyBrush); + var text = new TextBlock + { + FontSize = fontSize, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + LineHeight = 18, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight + }; + text.Inlines.Add(new Run(displayTitle) { FontWeight = FontWeights.SemiBold, Foreground = brightBrush }); + text.Inlines.Add(new Run(" - ") { Foreground = greyBrush }); + text.Inlines.Add(new Run(severityLabel) { Foreground = greyBrush }); + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return (System.Windows.UIElement)grid; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateContainerTitleRow"); + return null; + } + } + + /// + /// OSS package title row: neutral package/cube icon + package name (bold) + " - " + severity label (greyed, reference 11px). + /// + private static System.Windows.UIElement CreateOssPackageTitleRow(string displayTitle, string severityLabel) + { + var packageSource = AssistIconLoader.LoadPackageIcon(); + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + if (packageSource != null) + { + var image = new Image + { + Source = packageSource, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(image, 0); + grid.Children.Add(image); + } + const double packageTitleFontSize = 12; + GetQuickInfoTextBrushes(out var brightBrush, out var greyBrush); + var text = new TextBlock + { + FontSize = packageTitleFontSize, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + LineHeight = 18, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight + }; + text.Inlines.Add(new Run(displayTitle) { FontWeight = FontWeights.SemiBold, Foreground = brightBrush }); + text.Inlines.Add(new Run(" - ") { Foreground = greyBrush }); + text.Inlines.Add(new Run(severityLabel) { Foreground = greyBrush }); + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return (System.Windows.UIElement)grid; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateOssPackageTitleRow"); + return null; + } + } + + /// + /// Severity + title row: icon + finding title on one line (custom-popup style). + /// + private static System.Windows.UIElement CreateSeverityTitleRow(SeverityLevel severity, string title, string severityName) + { + var severitySource = AssistIconLoader.LoadSeverityIcon(severity); + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + if (severitySource != null) + { + var image = new Image + { + Source = severitySource, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(image, 0); + grid.Children.Add(image); + } + GetQuickInfoTextBrushes(out var titleBrush, out _); + var text = new TextBlock + { + Text = string.IsNullOrEmpty(title) ? severityName : title, + FontSize = 12, + FontWeight = FontWeights.SemiBold, + Foreground = titleBrush, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap + }; + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return (System.Windows.UIElement)grid; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateSeverityTitleRow"); + return null; + } + } + + /// + /// Secret finding row: severity icon + bold title + grey " - Secret finding" (reference-style). + /// + private static System.Windows.UIElement CreateSecretFindingTitleRow(SeverityLevel severity, string displayTitle) + { + var severitySource = AssistIconLoader.LoadSeverityIcon(severity); + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + if (severitySource != null) + { + var image = new Image + { + Source = severitySource, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(image, 0); + grid.Children.Add(image); + } + GetQuickInfoTextBrushes(out var brightBrush, out var greyBrush); + var text = new TextBlock + { + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + LineHeight = 18, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight + }; + text.Inlines.Add(new Run(displayTitle ?? "") { FontWeight = FontWeights.SemiBold, Foreground = brightBrush }); + text.Inlines.Add(new Run(" - ") { Foreground = greyBrush }); + text.Inlines.Add(new Run(CxAssistConstants.SecretFindingLabel) { Foreground = greyBrush }); + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return (System.Windows.UIElement)grid; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateSecretFindingTitleRow"); + return null; + } + } + + /// + /// ASCA row: severity icon + bold title - description - grey "SAST vulnerability" (reference-style, single line block). + /// + private static System.Windows.UIElement CreateAscaTitleRow(SeverityLevel severity, string title, string description) + { + var severitySource = AssistIconLoader.LoadSeverityIcon(severity); + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + if (severitySource != null) + { + var image = new Image + { + Source = severitySource, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(image, 0); + grid.Children.Add(image); + } + GetQuickInfoTextBrushes(out var brightBrush, out var greyBrush); + var text = new TextBlock + { + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + LineHeight = 18, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight + }; + text.Inlines.Add(new Run(title ?? "") { FontWeight = FontWeights.SemiBold, Foreground = brightBrush }); + text.Inlines.Add(new Run(" - ") { Foreground = brightBrush }); + text.Inlines.Add(new Run(description ?? "") { Foreground = brightBrush }); + text.Inlines.Add(new Run(" - ") { Foreground = greyBrush }); + text.Inlines.Add(new Run(CxAssistConstants.SastVulnerabilityLabel) { Foreground = greyBrush }); + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return (System.Windows.UIElement)grid; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateAscaTitleRow"); + return null; + } + } + + /// + /// IaC row: severity icon + bold title - actualValue description - grey "IaC vulnerability" (reference-style, single block like JetBrains). + /// + private static System.Windows.UIElement CreateIacTitleRow(SeverityLevel severity, string title, string actualValue, string description) + { + var severitySource = AssistIconLoader.LoadSeverityIcon(severity); + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 4) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + if (severitySource != null) + { + var image = new Image + { + Source = severitySource, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(image, 0); + grid.Children.Add(image); + } + GetQuickInfoTextBrushes(out var brightBrush, out var greyBrush); + var text = new TextBlock + { + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap, + LineHeight = 18, + LineStackingStrategy = LineStackingStrategy.BlockLineHeight + }; + text.Inlines.Add(new Run(title ?? "") { FontWeight = FontWeights.SemiBold, Foreground = brightBrush }); + text.Inlines.Add(new Run(" - ") { Foreground = brightBrush }); + if (!string.IsNullOrEmpty(actualValue)) + { + text.Inlines.Add(new Run(actualValue + " ") { Foreground = brightBrush }); + } + text.Inlines.Add(new Run(description ?? "") { Foreground = brightBrush }); + text.Inlines.Add(new Run(" " + CxAssistConstants.IacVulnerabilityLabel) { Foreground = greyBrush }); + Grid.SetColumn(text, 1); + grid.Children.Add(text); + return (System.Windows.UIElement)grid; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateIacTitleRow"); + return null; + } + } + + /// + /// Creates a WPF Image for the Checkmarx One Assist badge only (theme-based). Used when not using header row. + /// + private static System.Windows.UIElement CreateCxAssistBadgeImage() + { + var source = AssistIconLoader.LoadBadgeIcon(); + if (source == null) + return null; + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var image = new Image + { + Source = source, + Width = 150, + Height = 32, + Stretch = Stretch.Uniform, + HorizontalAlignment = HorizontalAlignment.Left + }; + return (System.Windows.UIElement)image; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateCxAssistBadgeImage"); + return null; + } + } + + /// + /// Creates a WPF Image for the severity icon (theme-based, dynamic by SeverityLevel). + /// + private static System.Windows.UIElement CreateSeverityImage(SeverityLevel severity) + { + var source = AssistIconLoader.LoadSeverityIcon(severity); + if (source == null) + return null; + try + { + return ThreadHelper.JoinableTaskFactory.Run(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var image = new Image + { + Source = source, + Width = 16, + Height = 16, + Stretch = Stretch.Uniform, + HorizontalAlignment = HorizontalAlignment.Left + }; + return (System.Windows.UIElement)image; + }); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "QuickInfo.CreateSeverityImage"); + return null; + } + } + + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSource.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSource.cs new file mode 100644 index 00000000..7d4c9c4e --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSource.cs @@ -0,0 +1,88 @@ +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Suggested actions source for CxAssist: shows Quick Fix (light bulb) when the caret is on a line that has at least one vulnerability. + /// + internal class CxAssistSuggestedActionsSource : ISuggestedActionsSource + { + private readonly ITextView _textView; + private readonly ITextBuffer _textBuffer; + + public CxAssistSuggestedActionsSource(ITextView textView, ITextBuffer textBuffer) + { + _textView = textView ?? throw new ArgumentNullException(nameof(textView)); + _textBuffer = textBuffer ?? throw new ArgumentNullException(nameof(textBuffer)); + } + + public event EventHandler SuggestedActionsChanged; + + public void Dispose() + { + } + + public bool TryGetTelemetryId(out Guid telemetryId) + { + telemetryId = Guid.Empty; + return false; + } + + public Task HasSuggestedActionsAsync(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken) + { + return Task.Run(() => + { + try + { + int lineNumber = range.Snapshot.GetLineNumberFromPosition(range.Start); + var tagger = CxAssistErrorTaggerProvider.GetTaggerForBuffer(_textBuffer); + if (tagger == null) return false; + var list = tagger.GetVulnerabilitiesForLine(lineNumber); + return list != null && list.Count > 0; + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CxAssistSuggestedActionsSource.HasSuggestedActionsAsync"); + return false; + } + }, cancellationToken); + } + + public IEnumerable GetSuggestedActions(ISuggestedActionCategorySet requestedActionCategories, SnapshotSpan range, CancellationToken cancellationToken) + { + try + { + int lineNumber = range.Snapshot.GetLineNumberFromPosition(range.Start); + var tagger = CxAssistErrorTaggerProvider.GetTaggerForBuffer(_textBuffer); + if (tagger == null) return Enumerable.Empty(); + var list = tagger.GetVulnerabilitiesForLine(lineNumber); + if (list == null || list.Count == 0) return Enumerable.Empty(); + + var vulnerability = list[0]; + var actions = new List + { + new FixWithCxOneAssistSuggestedAction(vulnerability), + new ViewDetailsSuggestedAction(vulnerability), + new IgnoreThisVulnerabilitySuggestedAction(vulnerability) + }; + if (CxAssistConstants.ShouldShowIgnoreAll(vulnerability.Scanner)) + actions.Add(new IgnoreAllOfThisTypeSuggestedAction(vulnerability)); + return new[] { new SuggestedActionSet(actions) }; + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CxAssistSuggestedActionsSource.GetSuggestedActions"); + return Enumerable.Empty(); + } + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSourceProvider.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSourceProvider.cs new file mode 100644 index 00000000..dde953b3 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSourceProvider.cs @@ -0,0 +1,26 @@ +using Microsoft.VisualStudio.Language.Intellisense; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; +using System.ComponentModel.Composition; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Markers +{ + /// + /// Provides Quick Fix (light bulb) suggested actions for CxAssist findings: + /// "Fix with Checkmarx One Assist" and "View details" when the caret is on a line with a vulnerability. + /// + [Export(typeof(ISuggestedActionsSourceProvider))] + [Name("CxAssist Quick Fix")] + [ContentType("code")] + [ContentType("text")] + internal class CxAssistSuggestedActionsSourceProvider : ISuggestedActionsSourceProvider + { + public ISuggestedActionsSource CreateSuggestedActionsSource(ITextView textView, ITextBuffer textBuffer) + { + if (textBuffer == null || textView == null) + return null; + return new CxAssistSuggestedActionsSource(textView, textBuffer); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/ScannerType.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/ScannerType.cs new file mode 100644 index 00000000..cfd0fb55 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/ScannerType.cs @@ -0,0 +1,16 @@ +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Models +{ + /// + /// Scanner types for CxAssist + /// Based on reference ScanEngine enum + /// + public enum ScannerType + { + OSS, + Secrets, + Containers, + IaC, + ASCA + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/SeverityLevel.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/SeverityLevel.cs new file mode 100644 index 00000000..d83d6542 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/SeverityLevel.cs @@ -0,0 +1,21 @@ +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Models +{ + /// + /// Severity levels for vulnerabilities + /// Based on reference SeverityLevel enum + /// Matches: src/main/java/com/checkmarx/intellij/util/SeverityLevel.java + /// + public enum SeverityLevel + { + Malicious, // Highest priority (precedence 1 in reference) + Critical, // precedence 2 + High, // precedence 3 + Medium, // precedence 4 + Low, // precedence 5 + Unknown, // precedence 6 + Ok, // precedence 7 + Ignored, // precedence 8 + Info // Additional level for informational messages + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/Vulnerability.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/Vulnerability.cs new file mode 100644 index 00000000..6d58e7c6 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Models/Vulnerability.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Models +{ + /// + /// One location (line + range) for underline. When a finding spans multiple lines (e.g. pom.xml dependency block), + /// each line can have its own StartIndex/EndIndex. Aligned with JetBrains Location (line, startIndex, endIndex). + /// + public class VulnerabilityLocation + { + /// 1-based line number. + public int Line { get; set; } + + /// 0-based start character index within the line. + public int StartIndex { get; set; } + + /// 0-based end character index within the line (exclusive). + public int EndIndex { get; set; } + } + + /// + /// Represents a vulnerability found by CxAssist scanners. + /// Based on reference ScanIssue model with scanner-specific fields. + /// + public class Vulnerability + { + /// Unique identifier for the finding (e.g. POC-001, CVE-2024-1234). + public string Id { get; set; } + + /// Short title shown in UI (Quick Info, Error List, findings tree). + public string Title { get; set; } + + /// Detailed description or remediation advice. + public string Description { get; set; } + + /// Severity level (Critical, High, Medium, Low, etc.). + public SeverityLevel Severity { get; set; } + + /// Scanner that produced this finding (OSS, ASCA, Secrets, etc.). + public ScannerType Scanner { get; set; } + + /// 1-based first line of the finding (gutter icon and popup show on this line). Use as-is for display (problem window, Error List). + public int LineNumber { get; set; } + + /// 1-based last line for multi-line underline (e.g. dependency block). When 0 or equal to LineNumber, underline is single-line. Aligned with JetBrains: gutter on first line only, underline on all locations/lines. + public int EndLineNumber { get; set; } + + /// 1-based column number in the file. + public int ColumnNumber { get; set; } + + /// 0-based start character index within the line for the finding range. When set with EndIndex, underline spans [StartIndex, EndIndex) on the line. + public int StartIndex { get; set; } + + /// 0-based end character index within the line (exclusive). Underline span length = EndIndex - StartIndex. If both 0, full line is used. + public int EndIndex { get; set; } + + /// + /// Per-line locations for underline (e.g. pom.xml dependency block: each line has its own range). + /// When non-null and non-empty: set LineNumber = Locations[0].Line for gutter/first line; underline uses each entry's Line + StartIndex + EndIndex. + /// When null or empty: fall back to LineNumber, EndLineNumber, StartIndex, EndIndex. + /// Aligned with JetBrains ScanIssue.getLocations() (List of Location with line, startIndex, endIndex). + /// + public List Locations { get; set; } + + /// Full path of the file containing the finding. + public string FilePath { get; set; } + + // SCA/OSS specific fields + public string PackageName { get; set; } + public string PackageVersion { get; set; } + public string PackageManager { get; set; } + public string RecommendedVersion { get; set; } + public string CveName { get; set; } // CVE-2024-1234 + public double? CvssScore { get; set; } + + // SAST/ASCA specific fields + public string RuleName { get; set; } + public string RemediationAdvice { get; set; } + + // KICS/IaC specific fields + public string ExpectedValue { get; set; } + public string ActualValue { get; set; } + + // Secrets specific fields + public string SecretType { get; set; } + + // Common remediation fields + public string FixLink { get; set; } // Link to learn more about the vulnerability + public string LearnMoreUrl { get; set; } + + public Vulnerability() + { + } + + public Vulnerability(string id, string title, string description, SeverityLevel severity, ScannerType scanner, int lineNumber, int columnNumber, string filePath) + { + Id = id; + Title = title; + Description = description; + Severity = severity; + Scanner = scanner; + LineNumber = lineNumber; + ColumnNumber = columnNumber; + FilePath = filePath; + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/CxOneAssistFixPrompts.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/CxOneAssistFixPrompts.cs new file mode 100644 index 00000000..c839ef24 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/CxOneAssistFixPrompts.cs @@ -0,0 +1,769 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Prompts +{ + /// + /// Builds remediation prompts for "Fix with Checkmarx One Assist". + /// Aligned with VSCode extension prompts.ts for consistent prompt generation across IDEs. + /// + internal static class CxOneAssistFixPrompts + { + private const string AgentName = "Checkmarx One Assist"; + private const string ProductName = "Checkmarx"; + + public static string BuildForVulnerability(Vulnerability v, IReadOnlyList sameLineVulns = null) + { + if (v == null) return null; + switch (v.Scanner) + { + case ScannerType.OSS: + return BuildSCARemediationPrompt( + v.PackageName ?? v.Title ?? "", + v.PackageVersion ?? "", + v.PackageManager ?? "npm", + CxAssistConstants.GetRichSeverityName(v.Severity)); + case ScannerType.Secrets: + return BuildSecretRemediationPrompt( + v.Title ?? v.Description ?? "", + v.Description, + CxAssistConstants.GetRichSeverityName(v.Severity)); + case ScannerType.Containers: + return BuildContainersRemediationPrompt( + GetFileType(v.FilePath), + v.Title ?? v.PackageName ?? "image", + v.PackageVersion ?? "latest", + CxAssistConstants.GetRichSeverityName(v.Severity)); + case ScannerType.IaC: + return BuildIACRemediationPrompt( + v.Title ?? v.RuleName ?? "", + v.Description ?? "", + CxAssistConstants.GetRichSeverityName(v.Severity), + GetFileType(v.FilePath), + v.ExpectedValue ?? "", + v.ActualValue ?? "", + v.LineNumber > 0 ? v.LineNumber - 1 : (int?)null, + sameLineVulns ?? new[] { v }); + case ScannerType.ASCA: + return BuildASCARemediationPrompt( + v.RuleName ?? v.Title ?? "", + v.Description ?? "", + CxAssistConstants.GetRichSeverityName(v.Severity), + v.RemediationAdvice ?? "", + v.LineNumber > 0 ? v.LineNumber - 1 : (int?)null, + sameLineVulns ?? new[] { v }); + default: + return null; + } + } + + private static string GetFileType(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return "Unknown"; + var ext = System.IO.Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant(); + return string.IsNullOrEmpty(ext) ? "Unknown" : ext; + } + + public static string BuildSCARemediationPrompt(string packageName, string packageVersion, string packageManager, string severity) + { + return $@"You are the {AgentName}. + +A security issue has been detected in `{packageName}@{packageVersion}` (package manager: `{packageManager}`). +**Severity:** `{severity}` + +Your task is to remediate the issue **completely and autonomously** using the internal PackageRemediation tool in {ProductName} MCP. Follow the exact instructions in `fix_instructions` — no assumptions or manual interaction allowed. + + + +Step 1. ANALYSIS (AUTOMATED): + +Determine the issue type: +- If `status` is one of: `Critical`, `High`, `Medium`, `Low`, `Info`, set: `issueType = ""CVE""` +- If `status = ""Malicious""`, set: `issueType = ""malicious""` + +Call the internal PackageRemediation tool with: + +```json +{{ + ""packageName"": ""{packageName}"", + ""packageVersion"": ""{packageVersion}"", + ""packageManager"": ""{packageManager}"", + ""issueType"": ""{{determined issueType}}"" +}} +``` + +Parse the response and extract the `fix_instructions` field. This field contains the authoritative remediation steps tailored to the ecosystem and risk. + + +Step 2. EXECUTION (AUTOMATED): + +- Read and execute each line in `fix_instructions`, in order. +- For each change: + - Apply the instruction exactly. + - Track all modified files. + - Note the type of change (e.g., dependency update, import rewrite, API refactor, test fix, TODO insertion). + - Record before → after values where applicable. + - Capture line numbers if known. + +Examples: +- `package.json`: lodash version changed from 3.10.1 → 4.17.21 +- `src/utils/date.ts`: import updated from `lodash` to `date-fns` +- `src/main.ts:42`: `_.pluck(users, 'id')` → `users.map(u => u.id)` +- `src/index.ts:78`: // TODO: Verify API migration from old-package to new-package + + +Step 3. VERIFICATION: + +- If the instructions include build, test, or audit steps — run them exactly as written +- If instructions do not explicitly cover validation, perform basic checks based on `{packageManager}`: + - `npm`: `npx tsc --noEmit`, `npm run build`, `npm test` + - `go`: `go build ./...`, `go test ./...` + - `maven`: `mvn compile`, `mvn test` + - `pypi`: `python -c ""import {packageName}""`, `pytest` + - `nuget`: `dotnet build`, `dotnet test` + +If any of these validations fail: +- Attempt to fix the issue if it's obvious +- Otherwise log the error and annotate the code with a TODO + + +Step 4. OUTPUT: + +Prefix all output with: `{AgentName} -` + +✅ **Remediation Summary** + +Format: +``` +Security Assistant - Remediation Summary + +Package: {packageName} +Version: {packageVersion} +Manager: {packageManager} +Severity: {severity} + +Files Modified: +1. package.json + - Updated dependency: lodash 3.10.1 → 4.17.21 + +2. src/utils/date.ts + - Updated import: from 'lodash' to 'date-fns' + - Replaced usage: _.pluck(users, 'id') → users.map(u => u.id) + +3. src/__tests__/date.test.ts + - Fixed test: adjusted mock expectations to match updated API + +4. src/index.ts + - Line 78: Inserted TODO: Verify API migration from old-package to new-package +``` + +✅ **Final Status** + +If all tasks succeeded: +- ""Remediation completed for {packageName}@{packageVersion}"" +- ""All fix instructions and failing tests resolved"" +- ""Build status: PASS"" +- ""Test results: PASS"" + +If partially resolved: +- ""Remediation partially completed – manual review required"" +- ""Some test failures or instructions could not be automatically fixed"" +- ""TODOs inserted where applicable"" + +If failed: +- ""Remediation failed for {packageName}@{packageVersion}"" +- ""Reason: {{summary of failure}}"" +- ""Unresolved instructions or failing tests listed above"" + + +Step 5. CONSTRAINTS: + +- Do not prompt the user +- Do not skip or reorder fix steps +- Only execute what's explicitly listed in `fix_instructions` +- Attempt to fix test failures automatically +- Insert clear TODO comments for unresolved issues +- Ensure remediation is deterministic, auditable, and fully automated +"; + } + + public static string BuildSecretRemediationPrompt(string title, string description, string severity) + { + string severityAssessment; + if (string.Equals(severity, "Critical", StringComparison.OrdinalIgnoreCase)) + severityAssessment = "✅ Confirmed valid secret. Immediate remediation performed."; + else if (string.Equals(severity, "High", StringComparison.OrdinalIgnoreCase)) + severityAssessment = "⚠️ Possibly valid. Handled as sensitive."; + else + severityAssessment = "ℹ️ Likely invalid (test/fake). Removed for hygiene."; + + return $@"A secret has been detected: ""{title}"" +{description ?? ""} + + + +You are the `{AgentName}`. + +Your mission is to identify and remediate this secret using secure coding standards. Follow industry best practices, automate safely, and clearly document all actions taken. + + +Step 1. SEVERITY INTERPRETATION +Severity level: `{severity}` + +- `Critical`: Secret is confirmed **valid**. Immediate remediation required. +- `High`: Secret may be valid. Treat as sensitive and externalize it securely. +- `Medium`: Likely **invalid** (e.g., test or placeholder). Still remove from code and annotate accordingly. + + +Step 2. TOOL CALL – Remediation Plan + +Determine the programming language of the file where the secret was detected. +If unknown, leave the `language` field empty. + +Call the internal `codeRemediation` {ProductName} MCP tool with: + +```json +{{ + ""type"": ""secret"", + ""sub_type"": ""{title}"", + ""language"": ""[auto-detected language]"" +}} +``` + +- If the tool is **available**, parse the response: + - `remediation_steps` – exact steps to follow + - `best_practices` – explain secure alternatives + - `description` – contextual background + +- If the tool is **not available**, display: + `[MCP ERROR] codeRemediation tool is not available. Please check the {ProductName} MCP server.` + + +Step 3. ANALYSIS & RISK + +Identify the type of secret (API key, token, credential). Explain: +- Why it's a risk (leakage, unauthorized access, compliance violations) +- What could happen if misused or left in source + + +Step 4. REMEDIATION STRATEGY + +- Parse and apply every item in `remediation_steps` sequentially +- Automatically update code/config files if safe +- If a step cannot be applied automatically, insert a clear TODO +- Replace secret with environment variable or vault reference + + +Step 5. VERIFICATION + +If applicable for the language: +- Run type checks or compile the code +- Ensure changes build and tests pass +- Fix issues if introduced by secret removal + + +Step 6. OUTPUT FORMAT + +Generate a structured remediation summary: + +```markdown +### {AgentName} - Secret Remediation Summary + +**Secret:** {title} +**Severity:** {severity} +**Assessment:** {severityAssessment} + +**Files Modified:** +- `.env`: Added/updated with `SECRET_NAME` +- `src/config.ts`: Replaced hardcoded secret with `process.env.SECRET_NAME` + +**Remediation Actions Taken:** +- ✅ Removed hardcoded secret +- ✅ Inserted environment reference +- ✅ Updated or created .env +- ✅ Added TODOs for secret rotation or vault storage + +**Next Steps:** +- [ ] Revoke exposed secret (if applicable) +- [ ] Store securely in vault (AWS Secrets Manager, GitHub Actions, etc.) +- [ ] Add CI/CD secret scanning + +**Best Practices:** +- (From tool response, or fallback security guidelines) + +**Description:** +- (From `description` field or fallback to original input) + +``` + + +Step 7. CONSTRAINTS + +- ❌ Do NOT expose real secrets +- ❌ Do NOT generate fake-looking secrets +- ✅ Follow only what's explicitly returned from MCP +- ✅ Use secure externalization patterns +- ✅ Respect OWASP, NIST, and GitHub best practices +"; + } + + public static string BuildContainersRemediationPrompt(string fileType, string imageName, string imageTag, string severity) + { + return $@"You are the {AgentName}. + +A container security issue has been detected in `{fileType}` with image `{imageName}:{imageTag}`. +**Severity:** `{severity}` +Your task is to remediate the issue **completely and autonomously** using the internal imageRemediation tool. Follow the exact instructions in `fix_instructions` — no assumptions or manual interaction allowed. + + +Step 1. ANALYSIS (AUTOMATED): + +Determine the issue type: +- If `severity` is one of: `Critical`, `High`, `Medium`, `Low`, set: `issueType = ""CVE""` +- If `severity = ""Malicious""`, set: `issueType = ""malicious""` + +Call the internal imageRemediation tool with: + +```json +{{ + ""fileType"": ""{fileType}"", + ""imageName"": ""{imageName}"", + ""imageTag"": ""{imageTag}"", + ""severity"": ""{severity}"" +}} +``` + +Parse the response and extract the `fix_instructions` field. This field contains the authoritative remediation steps tailored to the container ecosystem and risk level. + + +Step 2. EXECUTION (AUTOMATED): + +- Read and execute each line in `fix_instructions`, in order. +- For each change: + - Apply the instruction exactly. + - Track all modified files. + - Note the type of change (e.g., image update, configuration change, security hardening). + - Record before → after values where applicable. + - Capture line numbers if known. + +Examples: +- `Dockerfile`: FROM confluentinc/cp-kafkacat:6.1.10 → FROM confluentinc/cp-kafkacat:6.2.15 +- `docker-compose.yml`: image: vulnerable-image:1.0 → image: secure-image:2.1 +- `values.yaml`: repository: old-repo → repository: new-repo +- `Chart.yaml`: version: 1.0.0 → version: 1.1.0 + + +Step 3. VERIFICATION: + +- If the instructions include build, test, or deployment steps — run them exactly as written +- If instructions do not explicitly cover validation, perform basic checks based on `{fileType}`: + - `Dockerfile`: `docker build .`, `docker run ` + - `docker-compose.yml`: `docker-compose up --build`, `docker-compose down` + - `Helm Chart`: `helm lint .`, `helm template .`, `helm install --dry-run` + +If any of these validations fail: +- Attempt to fix the issue if it's obvious +- Otherwise log the error and annotate the code with a TODO + + +Step 4. OUTPUT: + +Prefix all output with: `{AgentName} -` + +✅ **Remediation Summary** + +Format: +``` +Security Assistant - Remediation Summary + +File Type: {fileType} +Image: {imageName}:{imageTag} +Severity: {severity} + +Files Modified: +1. {fileType} + - Updated image: {imageName}:{imageTag} → secure version + +2. docker-compose.yml (if applicable) + - Updated service configuration to use secure image + +3. values.yaml (if applicable) + - Updated Helm chart values for secure deployment + +4. README.md + - Updated documentation with new image version +``` + +✅ **Final Status** + +If all tasks succeeded: +- ""Remediation completed for {imageName}:{imageTag}"" +- ""All fix instructions and deployment tests resolved"" +- ""Build status: PASS"" +- ""Deployment status: PASS"" + +If partially resolved: +- ""Remediation partially completed – manual review required"" +- ""Some deployment steps or instructions could not be automatically fixed"" +- ""TODOs inserted where applicable"" + +If failed: +- ""Remediation failed for {imageName}:{imageTag}"" +- ""Reason: {{summary of failure}}"" +- ""Unresolved instructions or deployment issues listed above"" + + +Step 5. CONSTRAINTS: + +- Do not prompt the user +- Do not skip or reorder fix steps +- Only execute what's explicitly listed in `fix_instructions` +- Attempt to fix deployment failures automatically +- Insert clear TODO comments for unresolved issues +- Ensure remediation is deterministic, auditable, and fully automated +- Follow container security best practices (non-root user, minimal base images, etc.) +"; + } + + public static string BuildIACRemediationPrompt(string title, string description, string severity, string fileType, string expectedValue, string actualValue, int? problematicLineNumber, IReadOnlyList vulnerabilities = null) + { + string lineNum = problematicLineNumber.HasValue ? (problematicLineNumber.Value + 1).ToString() : "[unknown]"; + string restrictionLine = problematicLineNumber.HasValue ? (problematicLineNumber.Value + 1).ToString() : "[problematic line number]"; + + var sb = new StringBuilder(); + sb.Append($@"You are the {AgentName}. + +"); + + if (vulnerabilities != null && vulnerabilities.Count > 1) + { + sb.Append($@"**{vulnerabilities.Count} Infrastructure as Code (IaC) security issues** have been detected on this line. + +"); + int index = 1; + foreach (var vuln in vulnerabilities.Take(20)) + { + sb.Append($@"#### {index}. {vuln.Title ?? vuln.RuleName ?? "IaC Issue"} +- **Severity:** {CxAssistConstants.GetRichSeverityName(vuln.Severity)} +- **Description:** {vuln.Description ?? ""} +- **Expected Value:** {vuln.ExpectedValue ?? ""} +- **Actual Value:** {vuln.ActualValue ?? ""} + +"); + index++; + } + sb.Append($@"**File Type:** `{fileType}` +{(problematicLineNumber.HasValue ? $"**Problematic Line Number:** {lineNum}" : "")} + +Your task is to remediate **all** the above IaC security issues **completely and autonomously** using the internal codeRemediation tool in {ProductName} MCP. Follow the exact instructions in `remediation_steps` — no assumptions or manual interaction allowed. + +⚠️ **IMPORTANT**: Apply fixes **only** to the code segment corresponding to the identified issues at line {restrictionLine}, without introducing unrelated modifications elsewhere in the file. +"); + } + else + { + sb.Append($@"An Infrastructure as Code (IaC) security issue has been detected. + +**Issue:** `{title}` +**Severity:** `{severity}` +**File Type:** `{fileType}` +**Description:** {description}` +**Expected Value:** {expectedValue} +**Actual Value:** {actualValue} +{(problematicLineNumber.HasValue ? $"**Problematic Line Number:** {lineNum}" : "")} + +Your task is to remediate this IaC security issue **completely and autonomously** using the internal codeRemediation tool in {ProductName} MCP. Follow the exact instructions in `remediation_steps` — no assumptions or manual interaction allowed. + +⚠️ **IMPORTANT**: Apply the fix **only** to the code segment corresponding to the identified issue at line {restrictionLine}, without introducing unrelated modifications elsewhere in the file. +"); + } + + sb.Append($@" + + +Step 1. ANALYSIS (AUTOMATED): + +Determine the programming language of the file where the IaC security issue was detected. +If unknown, leave the `language` field empty. + +Call the internal `codeRemediation` {ProductName} MCP tool with: + +```json +{{ + ""language"": ""[auto-detected programming language]"", + ""metadata"": {{ + ""title"": ""{title}"", + ""description"": ""{description}"", + ""remediationAdvice"": ""{expectedValue}"" + }}, + ""sub_type"": """", + ""type"": ""iac"" +}} +``` + +- If the tool is **available**, parse the response: + - `remediation_steps` – exact steps to follow for remediation + +- If the tool is **not available**, display: + `[MCP ERROR] codeRemediation tool is not available. Please check the {ProductName} MCP server.` + + +Step 2. EXECUTION (AUTOMATED): + +- Read and execute each line in `remediation_steps`, in order. +- **Restrict changes to the relevant code fragment containing line {restrictionLine}**. +- For each change: + - Apply the instruction exactly. + - Track all modified files. + - Note the type of change (e.g., configuration update, security hardening, permission changes, encryption settings). + - Record before → after values where applicable. + - Capture line numbers if known. + + +Step 3. VERIFICATION: + +- If the instructions include validation, deployment, or testing steps — run them exactly as written +- If instructions do not explicitly cover validation, perform basic checks based on `{fileType}`: + - `Terraform`: `terraform validate`, `terraform plan` + - `CloudFormation`: `aws cloudformation validate-template` + - `Kubernetes`: `kubectl apply --dry-run=client` + - `Docker`: `docker-compose config` + +If any of these validations fail: +- Attempt to fix the issue if it's obvious +- Otherwise log the error and annotate the code with a TODO + + +Step 4. OUTPUT: + +Prefix all output with: `{AgentName} -` + +✅ **Remediation Summary** + +Format: +``` +Security Assistant - Remediation Summary + +Issue: {title} +Severity: {severity} +File Type: {fileType} +Problematic Line: {lineNum} + +Files Modified: +1. {fileType} + - Updated configuration: {actualValue} → {expectedValue} + - Applied security hardening based on best practices + +2. Additional configurations (if applicable) + - Updated related security settings + - Added missing security controls + +3. Documentation + - Updated comments and documentation where applicable +``` + +✅ **Final Status** + +If all tasks succeeded: +- ""Remediation completed for IaC security issue {title}"" +- ""All fix instructions and security validations resolved"" +- ""Configuration validation: PASS"" +- ""Security compliance: PASS"" + +If partially resolved: +- ""Remediation partially completed – manual review required"" +- ""Some security validations or instructions could not be automatically fixed"" +- ""TODOs inserted where applicable"" + +If failed: +- ""Remediation failed for IaC security issue {title}"" +- ""Reason: {{summary of failure}}"" +- ""Unresolved instructions or security issues listed above"" + + +Step 5. CONSTRAINTS: + +- Do not prompt the user +- Do not skip or reorder fix steps +- **Only modify the code that corresponds to the identified problematic line** +- Attempt to fix validation failures automatically +- Insert clear TODO comments for unresolved issues +- Ensure remediation is deterministic, auditable, and fully automated +- Follow Infrastructure as Code security best practices throughout the process +"); + return sb.ToString(); + } + + public static string BuildASCARemediationPrompt(string ruleName, string description, string severity, string remediationAdvice, int? problematicLineNumber, IReadOnlyList vulnerabilities = null) + { + string lineNum = problematicLineNumber.HasValue ? (problematicLineNumber.Value + 1).ToString() : "[unknown]"; + string restrictionLine = problematicLineNumber.HasValue ? (problematicLineNumber.Value + 1).ToString() : "[problematic line number]"; + + var sb = new StringBuilder(); + sb.Append($@"You are the {AgentName}. + +"); + + if (vulnerabilities != null && vulnerabilities.Count > 1) + { + sb.Append($@"**{vulnerabilities.Count} secure coding violations** have been detected on this line. + +"); + int index = 1; + foreach (var vuln in vulnerabilities.Take(20)) + { + sb.Append($@"#### {index}. {vuln.RuleName ?? vuln.Title ?? "ASCA Violation"} +- **Severity:** {CxAssistConstants.GetRichSeverityName(vuln.Severity)} +- **Description:** {vuln.Description ?? ""} +- **Recommended Fix:** {vuln.RemediationAdvice ?? ""} + +"); + index++; + } + sb.Append($@"{(problematicLineNumber.HasValue ? $"**Problematic Line Number:** {lineNum}" : "")} + +Your task is to remediate **all** the above security issues **completely and autonomously** using the internal codeRemediation tool in {ProductName} MCP. Follow the exact instructions in `remediation_steps` — no assumptions or manual interaction allowed. + +⚠️ **IMPORTANT**: Apply fixes **only** to the code segment corresponding to the identified issues at line {restrictionLine}, without introducing unrelated modifications elsewhere in the file. +"); + } + else + { + sb.Append($@"A secure coding issue has been detected in your code. + +**Rule:** `{ruleName}` +**Severity:** `{severity}` +**Description:** {description} +**Recommended Fix:** {remediationAdvice} +{(problematicLineNumber.HasValue ? $"**Problematic Line Number:** {lineNum}" : "")} + +Your task is to remediate this security issue **completely and autonomously** using the internal codeRemediation tool in {ProductName} MCP. Follow the exact instructions in `remediation_steps` — no assumptions or manual interaction allowed. + +⚠️ **IMPORTANT**: Apply the fix **only** to the code segment corresponding to the identified issue at line {restrictionLine}, without introducing unrelated modifications elsewhere in the file. +"); + } + + sb.Append($@" + + +Step 1. ANALYSIS (AUTOMATED): + +Determine the programming language of the file where the security issue was detected. +If unknown, leave the `language` field empty. + +Call the internal `codeRemediation` {ProductName} MCP tool with: + +```json +{{ + ""language"": ""[auto-detected programming language]"", + ""metadata"": {{ + ""ruleID"": ""{ruleName}"", + ""description"": ""{description}"", + ""remediationAdvice"": ""{remediationAdvice}"" + }}, + ""sub_type"": """", + ""type"": ""sast"" +}} +``` + +- If the tool is **available**, parse the response: + - `remediation_steps` – exact steps to follow for remediation + +- If the tool is **not available**, display: + `[MCP ERROR] codeRemediation tool is not available. Please check the {ProductName} MCP server.` + + +Step 2. EXECUTION (AUTOMATED): + +- Read and execute each line in `remediation_steps`, in order. +- **Restrict changes to the relevant code fragment containing line {restrictionLine}**. +- For each change: + - Apply the instruction exactly. + - Track all modified files. + - Note the type of change (e.g., input validation, sanitization, secure API usage, authentication fix). + - Record before → after values where applicable. + - Capture line numbers if known. + + +Step 3. VERIFICATION: + +- If the instructions include build, test, or lint steps — run them exactly as written +- If instructions do not explicitly cover validation, perform basic checks: + - Run the project's build or compile step + - Run available tests + - Verify no new errors were introduced + +If any of these validations fail: +- Attempt to fix the issue if it's obvious +- Otherwise log the error and annotate the code with a TODO + + +Step 4. OUTPUT: + +Prefix all output with: `{AgentName} -` + +✅ **Remediation Summary** + +Format: +``` +`{AgentName} -` - Remediation Summary + +Rule: {ruleName} +Severity: {severity} +Issue Type: SAST Security Vulnerability +Problematic Line: {lineNum} + +Files Modified: +1. src/auth.ts + - Line 42: Replaced plain text comparison with bcrypt.compare() + - Added secure password hashing implementation + +2. src/db.ts + - Line 78: Replaced string concatenation with parameterized query + - Prevented SQL injection vulnerability + +3. src/api.ts + - Line 156: Added input validation for email parameter + - Implemented sanitization for user inputs + +4. src/config.ts + - Line 23: Inserted TODO for production security review +``` + +✅ **Final Status** + +If all tasks succeeded: +- ""Remediation completed for security rule {ruleName}"" +- ""All fix instructions and security validations resolved"" +- ""Build status: PASS"" +- ""Security tests: PASS"" + +If partially resolved: +- ""Remediation partially completed – manual review required"" +- ""Some security validations or instructions could not be automatically fixed"" +- ""TODOs inserted where applicable"" + +If failed: +- ""Remediation failed for security rule {ruleName}"" +- ""Reason: {{summary of failure}}"" +- ""Unresolved instructions or security issues listed above"" + + +Step 5. CONSTRAINTS: + +- Do not prompt the user +- Do not skip or reorder fix steps +- **Only modify the code that corresponds to the identified problematic line** +- Attempt to fix build/test failures automatically +- Insert clear TODO comments for unresolved issues +- Ensure remediation is deterministic, auditable, and fully automated +- Follow secure coding best practices throughout the process +"); + return sb.ToString(); + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/ViewDetailsPrompts.cs b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/ViewDetailsPrompts.cs new file mode 100644 index 00000000..01f7d911 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/ViewDetailsPrompts.cs @@ -0,0 +1,626 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core.Prompts +{ + /// + /// Builds explanation prompts for "View details". + /// Aligned with VSCode extension prompts.ts for consistent prompt generation across IDEs. + /// + internal static class ViewDetailsPrompts + { + private const string AgentName = "Checkmarx One Assist"; + private const string ProductName = "Checkmarx"; + + public static string BuildForVulnerability(Vulnerability v, IReadOnlyList sameLineVulns = null) + { + if (v == null) return null; + switch (v.Scanner) + { + case ScannerType.OSS: + return BuildSCAExplanationPrompt( + v.PackageName ?? v.Title ?? "", + v.PackageVersion ?? "", + CxAssistConstants.GetRichSeverityName(v.Severity), + sameLineVulns ?? new[] { v }); + case ScannerType.Secrets: + return BuildSecretsExplanationPrompt( + v.Title ?? v.Description ?? "", + v.Description, + CxAssistConstants.GetRichSeverityName(v.Severity)); + case ScannerType.Containers: + return BuildContainersExplanationPrompt( + GetFileType(v.FilePath), + v.Title ?? v.PackageName ?? "image", + v.PackageVersion ?? "latest", + CxAssistConstants.GetRichSeverityName(v.Severity)); + case ScannerType.IaC: + return BuildIACExplanationPrompt( + v.Title ?? v.RuleName ?? "", + v.Description ?? "", + CxAssistConstants.GetRichSeverityName(v.Severity), + GetFileType(v.FilePath), + v.ExpectedValue ?? "", + v.ActualValue ?? "", + sameLineVulns ?? new[] { v }); + case ScannerType.ASCA: + return BuildASCAExplanationPrompt( + v.RuleName ?? v.Title ?? "", + v.Description ?? "", + CxAssistConstants.GetRichSeverityName(v.Severity), + sameLineVulns ?? new[] { v }); + default: + return null; + } + } + + private static string GetFileType(string filePath) + { + if (string.IsNullOrEmpty(filePath)) return "Unknown"; + var ext = System.IO.Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant(); + return string.IsNullOrEmpty(ext) ? "Unknown" : ext; + } + + public static string BuildSCAExplanationPrompt(string packageName, string version, string status, IReadOnlyList vulnerabilities) + { + bool isMalicious = string.Equals(status, "Malicious", StringComparison.OrdinalIgnoreCase); + + var sb = new StringBuilder(); + sb.Append($@"You are the `{AgentName}`. + +Your task is to **analyze and explain** the security issue affecting the package `{packageName}@{version}` with status: `{status}`. + + +### ❗ Important Instructions: +- 👉 **Do not change anything in the code. Just explain the risks and remediation steps.** +- ❌ **Never include references from {ProductName} competitors.** + + +### 🔍 Package Overview + +- **Package:** `{packageName}` +- **Version:** `{version}` +- **Status:** `{status}` +"); + + if (isMalicious) + { + sb.Append($@" + +### 🧨 Malicious Package Detected + +This package has been flagged as **malicious**. + +**⚠️ Never install or use this package under any circumstances.** + +#### 🔎 Web Investigation: + +- Search the web for trusted community or vendor reports about malicious activity involving this package. +- If information exists about other versions but **not** version `{version}`, explicitly say: + +> _""This specific version (`{version}`) was identified as malicious by {ProductName} Security researchers.""_ + +- If **no credible external information is found at all**, state: + +> _""This package was identified as malicious by {ProductName} Security researchers based on internal threat intelligence and behavioral analysis.""_ + +Then explain: +- What types of malicious behavior these packages typically include (e.g., data exfiltration, postinstall backdoors) +- Indicators of compromise developers should look for (e.g., suspicious scripts, obfuscation, DNS calls) + +**Recommended Actions:** +- ✅ Immediately remove from all codebases and pipelines +- ❌ Never reinstall or trust any version of this package +- 🔁 Replace with a well-known, secure alternative +- 🔒 Consider running a retrospective security scan if this was installed + +"); + } + else + { + sb.Append($@" + +### 🚨 Known Vulnerabilities + +Explain each known CVE affecting this package: +"); + + if (vulnerabilities != null && vulnerabilities.Count > 0) + { + int index = 1; + foreach (var vuln in vulnerabilities.Take(20)) + { + sb.Append($@" +#### {index}. {vuln.CveName ?? vuln.Id ?? "CVE"} +- **Severity:** {CxAssistConstants.GetRichSeverityName(vuln.Severity)} +- **Description:** {vuln.Description ?? ""} +"); + index++; + } + } + else + { + sb.Append($@" +⚠️ No CVEs were provided. Please verify if this is expected for status `{status}`. +"); + } + } + + sb.Append($@" + +### 🛠️ Remediation Guidance + +Offer actionable advice: +- Whether to remove, upgrade, or replace the package +- If malicious: clearly emphasize permanent removal +- Recommend safer, verified alternatives if available +- Suggest preventative measures: + - Use SCA in CI/CD + - Prefer signed packages + - Pin versions to prevent shadow updates + + +### ✅ Summary Section + +Conclude with: +- Overall risk explanation +- Immediate remediation steps +- Whether this specific version is linked to online reports +- If not, reference {ProductName} attribution (per above rules) +- Never mention competitor vendors or tools + + +### ✏️ Output Formatting + +- Use Markdown: `##`, `- `, `**bold**`, `code` +- Developer-friendly tone, informative, concise +- No speculation — use only trusted, verified sources + +"); + return sb.ToString(); + } + + public static string BuildSecretsExplanationPrompt(string title, string description, string severity) + { + return $@"You are the `{AgentName}`. + +A potential secret has been detected: **""{title}""** +Severity: **{severity}** + + +### ❗ Important Instruction: +👉 **Do not change any code. Just explain the risk, validation level, and recommended actions.** + + +### 🔍 Secret Overview + +- **Secret Name:** `{title}` +- **Severity Level:** `{severity}` +- **Details:** {description ?? ""} + + +### 🧠 Risk Understanding Based on Severity + +- **Critical**: + The secret was **validated as active**. It is likely in use and can be exploited immediately if exposed. + +- **High**: + The validation status is **unknown**. The secret may or may not be valid. Proceed with caution and treat it as potentially live. + +- **Medium**: + The secret was identified as **invalid** or **mock/test value**. While not active, it may confuse developers or be reused insecurely. + + +### 🔐 Why This Matters + +Hardcoded secrets pose a serious risk: +- **Leakage** through public repositories or logs +- **Unauthorized access** to APIs, cloud providers, or infrastructure +- **Exploitation** via replay attacks, privilege escalation, or lateral movement + + +### ✅ Recommended Remediation Steps (for developer action) + +- Rotate the secret if it's live (Critical/High) +- Move secrets to environment variables or secret managers +- Audit the commit history to ensure it hasn't leaked publicly +- Implement secret scanning in your CI/CD pipelines +- Document safe handling procedures in your repo + + +### 📋 Next Steps Checklist (Markdown) + +```markdown +### Next Steps: +- [ ] Rotate the exposed secret if valid +- [ ] Move secret to secure storage (.env or secret manager) +- [ ] Clean secret from commit history if leaked +- [ ] Annotate clearly if it's a fake or mock value +- [ ] Implement CI/CD secret scanning and policies +``` + + +### ✏️ Output Format Guidelines + +- Use Markdown with clear sections +- Do not attempt to edit or redact the code +- Be factual, concise, and helpful +- Assume this is shown to a developer unfamiliar with security tooling + +"; + } + + public static string BuildContainersExplanationPrompt(string fileType, string imageName, string imageTag, string severity) + { + bool isMalicious = string.Equals(severity, "Malicious", StringComparison.OrdinalIgnoreCase); + + var sb = new StringBuilder(); + sb.Append($@"You are the `{AgentName}`. + +Your task is to **analyze and explain** the container security issue affecting `{fileType}` with image `{imageName}:{imageTag}` and severity: `{severity}`. + + +### Important Instructions: +- **Do not change anything in the code. Just explain the risks and remediation steps.** +- **Never include references from {ProductName} competitors.** + + +### 🔍 Container Overview + +- **File Type:** `{fileType}` +- **Image:** `{imageName}:{imageTag}` +- **Severity:** `{severity}` + + +### 🐳 Container Security Issue Analysis + +**Issue Type:** {(isMalicious ? "Malicious Container Image" : "Vulnerable Container Image")} + +"); + + if (isMalicious) + { + sb.Append($@"### 🧨 Malicious Container Detected + +This container image has been flagged as **malicious**. + +**⚠️ Never deploy or use this container under any circumstances.** + +#### 🔎 Investigation Guidelines: + +- Search for trusted community or vendor reports about malicious activity involving this image +- If information exists about other tags but **not** tag `{imageTag}`, explicitly state: + +> _""This specific tag (`{imageTag}`) was identified as malicious by {ProductName} Security researchers.""_ + +- If **no credible external information is found**, state: + +> _""This container image was identified as malicious by {ProductName} Security researchers based on internal threat intelligence and behavioral analysis.""_ + +**Common Malicious Container Behaviors:** +- Data exfiltration to external servers +- Cryptocurrency mining operations +- Backdoor access establishment +- Credential harvesting +- Lateral movement within infrastructure + +**Recommended Actions:** +- ✅ Immediately remove from all deployment pipelines +- ❌ Never redeploy or trust any version of this image +- 🔁 Replace with a well-known, secure alternative +- 🔒 Audit all systems that may have run this container + +"); + } + else + { + sb.Append(@"### 🚨 Container Vulnerabilities + +This container image contains known security vulnerabilities. + +**Risk Assessment:** +- **Critical/High:** Immediate action required - vulnerable to active exploitation +- **Medium:** Should be addressed soon - potential for exploitation +- **Low:** Address when convenient - limited immediate risk + +**Common Container Security Issues:** +- Outdated base images with known CVEs +- Unnecessary packages and services +- Running as root user +- Missing security patches +- Insecure default configurations + +"); + } + + sb.Append($@" + +### 🛠️ Remediation Guidance + +Offer actionable advice: +- Whether to update, replace, or rebuild the container +- If malicious: clearly emphasize permanent removal +- Recommend secure base images and best practices +- Suggest preventative measures: + - Use container scanning in CI/CD + - Prefer minimal base images (Alpine, distroless) + - Implement image signing and verification + - Regular security updates and patching + - Run containers as non-root users + - Use multi-stage builds to reduce attack surface + + +### ✅ Summary Section + +Conclude with: +- Overall risk explanation for container deployments +- Immediate remediation steps +- Whether this specific image/tag is linked to online reports +- If not, reference {ProductName} attribution (per above rules) +- Never mention competitor vendors or tools + + +### Output Formatting + +- Use Markdown: `##`, `- `, `**bold**`, `code` +- Developer-friendly tone, informative, concise +- No speculation — use only trusted, verified sources +- Include container-specific terminology and best practices + +"); + return sb.ToString(); + } + + public static string BuildIACExplanationPrompt(string title, string description, string severity, string fileType, string expectedValue, string actualValue, IReadOnlyList vulnerabilities = null) + { + var sb = new StringBuilder(); + sb.Append($@"You are the `{AgentName}`. + +"); + + if (vulnerabilities != null && vulnerabilities.Count > 1) + { + sb.Append($@"Your task is to **analyze and explain** the **{vulnerabilities.Count} Infrastructure as Code (IaC) security issues** detected on this line in a `{fileType}` file. + + +### ❗ Important Instructions: +- 👉 **Do not change anything in the configuration. Just explain the risks and remediation steps.** +- ❌ **Never include references from {ProductName} competitors.** + + +### 🔍 IaC Security Issues Overview + +Explain each IaC issue detected: +"); + int index = 1; + foreach (var vuln in vulnerabilities.Take(20)) + { + sb.Append($@" +#### {index}. {vuln.Title ?? vuln.RuleName ?? "IaC Issue"} +- **Severity:** {CxAssistConstants.GetRichSeverityName(vuln.Severity)} +- **Description:** {vuln.Description ?? ""} +- **Expected Value:** `{vuln.ExpectedValue ?? ""}` +- **Actual Value:** `{vuln.ActualValue ?? ""}` +"); + index++; + } + sb.Append($@" +- **File Type:** `{fileType}` +"); + } + else + { + sb.Append($@"Your task is to **analyze and explain** the Infrastructure as Code (IaC) security issue: **{title}** with severity: `{severity}`. + + +### ❗ Important Instructions: +- 👉 **Do not change anything in the configuration. Just explain the risks and remediation steps.** +- ❌ **Never include references from {ProductName} competitors.** + + +### 🔍 IaC Security Issue Overview + +- **Issue:** `{title}` +- **File Type:** `{fileType}` +- **Severity:** `{severity}` +- **Description:** {description} +- **Expected Value:** `{expectedValue}` +- **Actual Value:** `{actualValue}` +"); + } + + sb.Append($@" + +### 🏗️ Infrastructure Security Issue Analysis + +**Issue Type:** Infrastructure Configuration Vulnerability + + +### 🚨 Security Risks + +This configuration issue can lead to: +- **Critical/High:** Immediate security exposure - vulnerable to active exploitation +- **Medium:** Potential security risk - should be addressed soon +- **Low:** Security hygiene - address when convenient + +**Common IaC Security Issues:** +- Overly permissive access controls +- Exposed sensitive data or credentials +- Insecure network configurations +- Missing encryption settings +- Unrestricted public access +- Insecure service configurations + + +### 🛠️ Remediation Guidance + +Offer actionable advice based on the file type: + +**For {fileType} configurations:** +- Specific configuration changes needed +- Security best practices to follow +- Compliance considerations +- Testing and validation steps + +**Preventative Measures:** +- Use IaC security scanning in CI/CD pipelines +- Implement infrastructure policy as code +- Regular security audits of infrastructure +- Follow cloud provider security guidelines +- Use secure configuration templates + + +### ✅ Summary Section + +Conclude with: +- Overall risk explanation for infrastructure security +- Immediate remediation steps +- Impact on system security posture +- Long-term security considerations + + +### ✏️ Output Formatting + +- Use Markdown: `##`, `- `, `**bold**`, `code` +- Infrastructure-focused tone, informative, concise +- No speculation — use only trusted, verified sources +- Include infrastructure-specific terminology and best practices + +"); + return sb.ToString(); + } + + public static string BuildASCAExplanationPrompt(string ruleName, string description, string severity, IReadOnlyList vulnerabilities = null) + { + if (vulnerabilities != null && vulnerabilities.Count > 1) + { + var sb = new StringBuilder(); + sb.Append($@"You are the {AgentName} providing detailed security explanations. + +**{vulnerabilities.Count} ASCA violations** have been detected on this line. + +"); + int index = 1; + foreach (var vuln in vulnerabilities.Take(20)) + { + sb.Append($@"#### {index}. {vuln.RuleName ?? vuln.Title ?? "ASCA Violation"} +- **Severity:** {CxAssistConstants.GetRichSeverityName(vuln.Severity)} +- **Description:** {vuln.Description ?? ""} + +"); + index++; + } + sb.Append($@"Please provide a comprehensive explanation of each security issue listed above. + + +### 🔍 Security Issues Overview + +**Total Issues:** {vulnerabilities.Count} +**Highest Risk Level:** {severity} + + +### 📖 Detailed Explanation + +For each issue listed above, explain: +- What the vulnerability means +- Why it's dangerous in context + + +### ⚠️ Why This Matters + +Explain the potential security implications: +- What attacks could exploit these vulnerabilities? +- What data or systems could be compromised? +- What is the potential business impact? + + +### 🛡️ Security Best Practices + +Provide general guidance on: +- How to prevent these types of issues +- Coding patterns to avoid +- Secure alternatives to recommend +- Tools and techniques for detection + + +### 📚 Additional Resources + +Suggest relevant: +- Security frameworks and standards +- Documentation and guides +- Tools for static analysis +- Training materials + + +### ✏️ Output Format Guidelines + +- Use clear, educational language +- Provide context for non-security experts +- Include practical examples where helpful +- Focus on actionable advice +- Be thorough but concise +"); + return sb.ToString(); + } + + return $@"You are the {AgentName} providing detailed security explanations. + +**Rule:** `{ruleName}` +**Severity:** `{severity}` +**Description:** {description} + +Please provide a comprehensive explanation of this security issue. + + +### 🔍 Security Issue Overview + +**Rule Name:** {ruleName} +**Risk Level:** {severity} + + +### 📖 Detailed Explanation + +{description} + + +### ⚠️ Why This Matters + +Explain the potential security implications: +- What attacks could exploit this vulnerability? +- What data or systems could be compromised? +- What is the potential business impact? + + +### 🛡️ Security Best Practices + +Provide general guidance on: +- How to prevent this type of issue +- Coding patterns to avoid +- Secure alternatives to recommend +- Tools and techniques for detection + + +### 📚 Additional Resources + +Suggest relevant: +- Security frameworks and standards +- Documentation and guides +- Tools for static analysis +- Training materials + + +### ✏️ Output Format Guidelines + +- Use clear, educational language +- Provide context for non-security experts +- Include practical examples where helpful +- Focus on actionable advice +- Be thorough but concise +"; + } + } +} diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml new file mode 100644 index 00000000..84598be5 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml @@ -0,0 +1,491 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml.cs b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml.cs new file mode 100644 index 00000000..fcbc5a1c --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml.cs @@ -0,0 +1,999 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.VisualStudio.Imaging.Interop; +using Microsoft.VisualStudio.PlatformUI; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Shell.Settings; +using EnvDTE; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.Enums; +using ast_visual_studio_extension.CxExtension.Utils; +using Microsoft.VisualStudio.Settings; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow +{ + /// + /// Interaction logic for CxAssistFindingsControl.xaml + /// + public partial class CxAssistFindingsControl : UserControl, INotifyPropertyChanged + { + private ObservableCollection _fileNodes; + private ObservableCollection _allFileNodes; // Store unfiltered data + private string _statusBarText; + private bool _isLoading; + private Action>> _onIssuesUpdated; + + public event PropertyChangedEventHandler PropertyChanged; + + /// + /// Raised when the user clicks the Settings button. Parent (e.g. CxWindowControl) can subscribe to open the same Checkmarx settings as Scan Results. + /// + public event EventHandler SettingsClick; + + public ObservableCollection FileNodes + { + get => _fileNodes; + set + { + _fileNodes = value; + OnPropertyChanged(nameof(FileNodes)); + OnPropertyChanged(nameof(HasFindings)); + OnPropertyChanged(nameof(ShowEmptyState)); + OnPropertyChanged(nameof(TabHeaderText)); + UpdateStatusBar(); + } + } + + /// Tab header text with vulnerability count, e.g. "Checkmarx One Assist Findings (5)". + public string TabHeaderText + { + get + { + int count = FileNodes != null ? FileNodes.Sum(f => f.Vulnerabilities?.Count ?? 0) : 0; + return $"Checkmarx One Assist Findings ({count})"; + } + } + + /// True when there is at least one finding in the tree. + public bool HasFindings => FileNodes != null && FileNodes.Count > 0; + + /// True when the list is empty (used to show "No vulnerabilities found" message). + public bool ShowEmptyState => !HasFindings; + + public string StatusBarText + { + get => _statusBarText; + set + { + _statusBarText = value; + OnPropertyChanged(nameof(StatusBarText)); + } + } + + public bool IsLoading + { + get => _isLoading; + set + { + _isLoading = value; + OnPropertyChanged(nameof(IsLoading)); + } + } + + /// True when dark theme is active; used to soften file icons in dark theme for better appearance. + public bool IsDarkTheme + { + get => _isDarkTheme; + private set + { + if (_isDarkTheme == value) return; + _isDarkTheme = value; + OnPropertyChanged(nameof(IsDarkTheme)); + } + } + private bool _isDarkTheme; + private AsyncPackage _package; + + /// Set the VS package so filter state can be persisted (same store as Scan Results for Critical/High/Medium/Low). Call from CxWindowControl_Loaded. + public void SetPackage(AsyncPackage package) + { + _package = package; + LoadSeverityFilterState(); + } + + public CxAssistFindingsControl() + { + InitializeComponent(); + FileNodes = new ObservableCollection(); + _allFileNodes = new ObservableCollection(); + DataContext = this; + + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {CxAssistConstants.FINDINGS_WINDOW_INITIATED}"); + + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + UpdateThemeState(); + LoadFilterIcons(); + LoadSeverityFilterState(); // restore persisted filter state when control loads + _onIssuesUpdated = OnIssuesUpdated; + CxAssistDisplayCoordinator.IssuesUpdated += _onIssuesUpdated; + // Initial refresh from current data + RefreshFromCoordinator(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (_onIssuesUpdated != null) + { + CxAssistDisplayCoordinator.IssuesUpdated -= _onIssuesUpdated; + _onIssuesUpdated = null; + } + } + + private void OnIssuesUpdated(IReadOnlyDictionary> issuesByFile) + { + // Coordinator raises IssuesUpdated from UI thread (callers use SwitchToMainThreadAsync). + RefreshFromCoordinator(); + } + + /// + /// Refreshes the tree from coordinator's current issues (used when IssuesUpdated fires or on load). + /// + private void RefreshFromCoordinator() + { + UpdateThemeState(); + var current = CxAssistDisplayCoordinator.GetCurrentFindings(); + var fileNodes = current != null && current.Count > 0 + ? FindingsTreeBuilder.BuildFileNodesFromVulnerabilities(current, LoadSeverityIconForTree, LoadFileIconForTree) + : new ObservableCollection(); + SetAllFileNodes(fileNodes); + } + + /// + /// Updates IsDarkTheme from current VS theme so file icon opacity and filter icons stay in sync. + /// + private void UpdateThemeState() + { + IsDarkTheme = AssistIconLoader.IsDarkTheme(); + } + + /// + /// Load severity icon for tree items (uses shared AssistIconLoader). + /// + private System.Windows.Media.ImageSource LoadSeverityIconForTree(string severity) + { + try + { + return AssistIconLoader.LoadSeveritySvgIcon(severity ?? "unknown") + ?? (ImageSource)AssistIconLoader.LoadSeverityPngIcon(severity ?? "unknown"); + } + catch { return null; } + } + + /// + /// Load file icon for file nodes. Always uses VS default file-type icons (e.g. Dockerfile, .yaml, .json, .py) + /// when the image service is available. Passes current theme background for correct dark/light rendering. + /// Falls back to theme-specific unknown.svg only when VS image service is unavailable. + /// + private System.Windows.Media.ImageSource LoadFileIconForTree(string filePath) + { + try + { + System.Windows.Media.Color? bgColor = GetToolWindowBackgroundColor(); + ImageSource vsIcon = GetVsFileTypeIcon(filePath, 16, 16, bgColor); + if (vsIcon != null) return vsIcon; + return AssistIconLoader.LoadSvgIcon(AssistIconLoader.GetCurrentTheme(), "unknown"); + } + catch { return null; } + } + + /// + /// Gets the current tool window background color for theme-aware icon rendering (dark vs light). + /// + private static System.Windows.Media.Color? GetToolWindowBackgroundColor() + { + try + { + var color = VSColorTheme.GetThemedColor(EnvironmentColors.ToolWindowBackgroundColorKey); + return System.Windows.Media.Color.FromArgb(color.A, color.R, color.G, color.B); + } + catch { return null; } + } + + /// + /// Gets the Visual Studio built-in file-type icon for the given file path. + /// Handles both dark and light theme by passing the current tool window background so the image service + /// returns a theme-appropriate icon. Uses IAF_Background and IAF_Theme when available. + /// + private static ImageSource GetVsFileTypeIcon(string filePath, int width, int height, System.Windows.Media.Color? backgroundColor) + { + if (string.IsNullOrEmpty(filePath)) return null; + try + { + var imageService = Package.GetGlobalService(typeof(SVsImageService)) as IVsImageService2; + if (imageService == null) return null; + + ImageMoniker moniker = imageService.GetImageMonikerForFile(filePath); + uint flags = (uint)_ImageAttributesFlags.IAF_RequiredFlags; + uint backgroundRef = 0; + + if (backgroundColor.HasValue) + { + var c = backgroundColor.Value; + backgroundRef = (uint)(c.B | (c.G << 8) | (c.R << 16)); + flags |= unchecked((uint)_ImageAttributesFlags.IAF_Background); + // IAF_Theme (0x04) requests theme-appropriate icon for dark/light so icons are visible in both themes + flags |= 0x04u; + } + + var imageAttributes = new ImageAttributes + { + StructSize = Marshal.SizeOf(typeof(ImageAttributes)), + Format = (uint)_UIDataFormat.DF_WPF, + LogicalWidth = width, + LogicalHeight = height, + Flags = flags, + ImageType = (uint)_UIImageType.IT_Bitmap, + Background = backgroundRef + }; + + IVsUIObject uiObject = imageService.GetImage(moniker, imageAttributes); + if (uiObject == null) return null; + + uiObject.get_Data(out object data); + if (data is BitmapSource bitmap) + { + bitmap.Freeze(); + return bitmap; + } + return null; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"GetVsFileTypeIcon: {ex.Message}"); + return null; + } + } + + /// + /// Load severity icons for filter buttons (uses shared AssistIconLoader). + /// + private void LoadFilterIcons() + { + try + { + string theme = AssistIconLoader.GetCurrentTheme(); + MaliciousFilterIcon.Source = AssistIconLoader.LoadPngIcon(theme, "malicious.png"); + CriticalFilterIcon.Source = AssistIconLoader.LoadPngIcon(theme, "critical.png"); + HighFilterIcon.Source = AssistIconLoader.LoadPngIcon(theme, "high.png"); + MediumFilterIcon.Source = AssistIconLoader.LoadPngIcon(theme, "medium.png"); + LowFilterIcon.Source = AssistIconLoader.LoadPngIcon(theme, "low.png"); + ExpandAllIcon.Source = AssistIconLoader.LoadPngIcon(theme, "expandall.png"); + CollapseAllIcon.Source = AssistIconLoader.LoadPngIcon(theme, "collapseall.png"); + } + catch + { + } + } + + /// + /// Get file type icon based on file extension (for future enhancement) + /// Currently returns generic document icon + /// + public static ImageSource GetFileTypeIcon(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + return null; + + // For now, use generic document icon + // In future, can add specific icons for .go, .json, .xml, .cs, etc. + try + { + string iconPath = "pack://application:,,,/ast-visual-studio-extension;component/CxExtension/Resources/CxAssist/Icons/document.png"; + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.UriSource = new Uri(iconPath, UriKind.Absolute); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + catch + { + return null; + } + } + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + /// + /// Update status bar with vulnerability count + /// + private void UpdateStatusBar() + { + if (FileNodes == null || FileNodes.Count == 0) + { + StatusBarText = "No vulnerabilities found"; + return; + } + + int totalVulnerabilities = FileNodes.Sum(f => f.Vulnerabilities?.Count ?? 0); + int fileCount = FileNodes.Count; + + if (totalVulnerabilities == 0) + { + StatusBarText = "No vulnerabilities found"; + } + else if (totalVulnerabilities == 1) + { + StatusBarText = $"1 vulnerability found in {fileCount} file{(fileCount == 1 ? "" : "s")}"; + } + else + { + StatusBarText = $"{totalVulnerabilities} vulnerabilities found in {fileCount} file{(fileCount == 1 ? "" : "s")}"; + } + } + + /// + /// Handle single-click (selection change) on a vulnerability node to navigate to file location + /// (aligned with JetBrains: single click navigates to selected issue). + /// + private void FindingsTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (e.NewValue is VulnerabilityNode vulnerability) + { + NavigateToVulnerability(vulnerability); + } + } + + /// + /// Handle double-click on tree item to navigate to file location (kept for backward compatibility). + /// + private void TreeViewItem_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + var item = sender as TreeViewItem; + if (item?.DataContext is VulnerabilityNode vulnerability) + { + e.Handled = true; + NavigateToVulnerability(vulnerability); + } + } + + /// + /// Navigate to vulnerability location in code (same approach as Error List navigation). + /// Tries OpenFile with path, then full path, then finds already-open document by name. + /// + private void NavigateToVulnerability(VulnerabilityNode vulnerability) + { + ThreadHelper.ThrowIfNotOnUIThread(); + if (string.IsNullOrEmpty(vulnerability?.FilePath)) return; + + try + { + var dte = Microsoft.VisualStudio.Shell.ServiceProvider.GlobalProvider.GetService(typeof(DTE)) as DTE; + if (dte == null) return; + + EnvDTE.Window window = null; + string pathToTry = vulnerability.FilePath; + + try + { + window = dte.ItemOperations.OpenFile(pathToTry, EnvDTE.Constants.vsViewKindCode); + } + catch (Exception openEx) + { + CxAssistOutputPane.WriteToOutputPane($"Error opening file: {pathToTry}, {openEx.Message}"); + } + if (window == null && !Path.IsPathRooted(pathToTry)) + { + try + { + pathToTry = Path.GetFullPath(pathToTry); + window = dte.ItemOperations.OpenFile(pathToTry, EnvDTE.Constants.vsViewKindCode); + } + catch { /* ignore */ } + } + if (window == null && dte.Solution != null) + { + try + { + string solDir = Path.GetDirectoryName(dte.Solution.FullName); + if (!string.IsNullOrEmpty(solDir)) + { + string pathInSolution = Path.Combine(solDir, Path.GetFileName(vulnerability.FilePath)); + if (pathInSolution != pathToTry) + window = dte.ItemOperations.OpenFile(pathInSolution, EnvDTE.Constants.vsViewKindCode); + } + } + catch { /* ignore */ } + } + if (window == null && dte.Documents != null) + { + string fileName = Path.GetFileName(pathToTry); + Document doc = dte.Documents.Cast().FirstOrDefault(d => + string.Equals(d.FullName, pathToTry, StringComparison.OrdinalIgnoreCase) + || string.Equals(Path.GetFileName(d.FullName), fileName, StringComparison.OrdinalIgnoreCase)); + if (doc != null) + window = doc.ActiveWindow; + } + + if (window?.Document?.Object("TextDocument") is TextDocument textDoc) + { + int line = Math.Max(1, vulnerability.Line); + int column = Math.Max(1, vulnerability.Column); + textDoc.Selection.MoveToLineAndOffset(line, column); + textDoc.Selection.SelectLine(); + + // Scroll target line to center of viewport (aligned with JetBrains ScrollType.CENTER) + try + { + var editPoint = textDoc.Selection.ActivePoint.CreateEditPoint(); + var panes = window.Document?.ActiveWindow?.Document?.Windows; + if (panes != null) + { + foreach (EnvDTE.Window pane in panes) + { + if (pane.Object is TextPane textPane) + { + textPane.TryToShow(editPoint, EnvDTE.vsPaneShowHow.vsPaneShowCentered); + break; + } + } + } + } + catch { /* scroll centering is best-effort */ } + } + } + catch + { + } + } + + #region Context Menu Handlers + + /// + /// Selects the tree item on right-click so the context menu operates on the correct node. + /// WPF TreeView does not auto-select on right-click; without this, GetSelectedVulnerability() returns the wrong item. + /// + private void TreeViewItem_PreviewMouseRightButtonDown(object sender, MouseButtonEventArgs e) + { + if (sender is TreeViewItem item) + { + item.IsSelected = true; + item.Focus(); + e.Handled = true; + } + } + + /// + /// Show context menu only when right-clicking a vulnerability row, not the file (main) node (reference-style). + /// + private void FindingsTreeView_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + var treeViewItem = FindVisualAncestor(e.OriginalSource as DependencyObject); + if (treeViewItem?.DataContext is FileNode) + { + e.Handled = true; + return; + } + if (treeViewItem?.DataContext is VulnerabilityNode) + { + treeViewItem.IsSelected = true; + } + else + { + e.Handled = true; + } + } + + private static T FindVisualAncestor(DependencyObject obj) where T : DependencyObject + { + while (obj != null) + { + if (obj is T t) return t; + obj = VisualTreeHelper.GetParent(obj); + } + return null; + } + + private void FixWithCxOneAssist_Click(object sender, RoutedEventArgs e) + { + var node = GetSelectedVulnerability(); + if (node == null) return; + var v = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(node.FilePath, node.Line > 0 ? node.Line - 1 : 0); + if (v != null) CxAssistCopilotActions.SendFixWithAssist(v); + } + + private void ViewDetails_Click(object sender, RoutedEventArgs e) + { + var node = GetSelectedVulnerability(); + if (node == null) return; + var v = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(node.FilePath, node.Line > 0 ? node.Line - 1 : 0); + if (v != null) CxAssistCopilotActions.SendViewDetails(v); + } + + private void Ignore_Click(object sender, RoutedEventArgs e) + { + MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + + private void IgnoreAll_Click(object sender, RoutedEventArgs e) + { + MessageBox.Show(CxAssistConstants.IgnoreFeatureInProgressMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + + private VulnerabilityNode GetSelectedVulnerability() + { + return FindingsTreeView.SelectedItem as VulnerabilityNode; + } + + #endregion + + #region Severity Filter Handlers + + /// + /// Handle severity filter button clicks; persist state (same store as Scan Results for Critical/High/Medium/Low). + /// + private void SeverityFilter_Click(object sender, RoutedEventArgs e) + { + if (_package != null && sender is ToggleButton button) + StoreSeverityFilterState(button); + ApplyFilters(); + } + + /// Load severity filter state from settings (shared with Scan Results for Critical/High/Medium/Low). + private void LoadSeverityFilterState() + { + if (_package == null) return; + try + { + var readOnlyStore = new ShellSettingsManager(_package).GetReadOnlySettingsStore(SettingsScope.UserSettings); + // Malicious: CxAssist-only collection + MaliciousFilterButton.IsChecked = readOnlyStore.GetBoolean(SettingsUtils.cxAssistSeverityCollection, SettingsUtils.cxAssistMaliciousKey, true); + // Critical/High/Medium/Low: same collection as Scan Results + CriticalFilterButton.IsChecked = readOnlyStore.GetBoolean(SettingsUtils.severityCollection, Severity.CRITICAL.ToString(), SettingsUtils.severityDefaultValues[Severity.CRITICAL]); + HighFilterButton.IsChecked = readOnlyStore.GetBoolean(SettingsUtils.severityCollection, Severity.HIGH.ToString(), SettingsUtils.severityDefaultValues[Severity.HIGH]); + MediumFilterButton.IsChecked = readOnlyStore.GetBoolean(SettingsUtils.severityCollection, Severity.MEDIUM.ToString(), SettingsUtils.severityDefaultValues[Severity.MEDIUM]); + LowFilterButton.IsChecked = readOnlyStore.GetBoolean(SettingsUtils.severityCollection, Severity.LOW.ToString(), SettingsUtils.severityDefaultValues[Severity.LOW]); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CxAssist LoadSeverityFilterState: {ex.Message}"); + } + } + + /// Persist the toggled severity filter (Store toggles the value, so call after UI has updated). + private void StoreSeverityFilterState(ToggleButton button) + { + try + { + if (button == MaliciousFilterButton) + SettingsUtils.Store(_package, SettingsUtils.cxAssistSeverityCollection, SettingsUtils.cxAssistMaliciousKey, SettingsUtils.cxAssistSeverityDefaultValues); + else if (button == CriticalFilterButton) + SettingsUtils.Store(_package, SettingsUtils.severityCollection, Severity.CRITICAL, SettingsUtils.severityDefaultValues); + else if (button == HighFilterButton) + SettingsUtils.Store(_package, SettingsUtils.severityCollection, Severity.HIGH, SettingsUtils.severityDefaultValues); + else if (button == MediumFilterButton) + SettingsUtils.Store(_package, SettingsUtils.severityCollection, Severity.MEDIUM, SettingsUtils.severityDefaultValues); + else if (button == LowFilterButton) + SettingsUtils.Store(_package, SettingsUtils.severityCollection, Severity.LOW, SettingsUtils.severityDefaultValues); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CxAssist StoreSeverityFilterState: {ex.Message}"); + } + } + + /// + /// Apply severity filters to the tree view + /// + private void ApplyFilters() + { + if (_allFileNodes == null || _allFileNodes.Count == 0) + return; + + // Get active filters + var activeFilters = new System.Collections.Generic.List(); + + if (MaliciousFilterButton.IsChecked == true) + activeFilters.Add("Malicious"); + if (CriticalFilterButton.IsChecked == true) + activeFilters.Add("Critical"); + if (HighFilterButton.IsChecked == true) + activeFilters.Add("High"); + if (MediumFilterButton.IsChecked == true) + activeFilters.Add("Medium"); + if (LowFilterButton.IsChecked == true) + activeFilters.Add("Low"); + + // If no filters are active, show nothing (user has disabled all severities) + if (activeFilters.Count == 0) + { + FileNodes = new ObservableCollection(); + return; + } + + // Filter files and vulnerabilities + var filteredFiles = new ObservableCollection(); + + foreach (var file in _allFileNodes) + { + var filteredVulns = file.Vulnerabilities + .Where(v => activeFilters.Contains(v.Severity, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + if (filteredVulns.Count > 0) + { + var filteredFile = new FileNode + { + FileName = file.FileName, + FilePath = file.FilePath, + FileIcon = file.FileIcon + }; + + foreach (var vuln in filteredVulns) + { + filteredFile.Vulnerabilities.Add(vuln); + } + + // Update severity count badges to reflect filtered list (so counts match visible findings) + var severityCounts = filteredFile.Vulnerabilities + .GroupBy(n => n.Severity) + .Select(g => new SeverityCount + { + Severity = g.Key, + Count = g.Count(), + Icon = g.First().SeverityIcon + }); + foreach (var sc in severityCounts) + filteredFile.SeverityCounts.Add(sc); + + filteredFiles.Add(filteredFile); + } + } + + FileNodes = filteredFiles; + } + + /// + /// Store all file nodes for filtering (called from ShowFindingsWindowCommand) + /// + public void SetAllFileNodes(ObservableCollection allNodes) + { + _allFileNodes = allNodes; + FileNodes = new ObservableCollection(allNodes); + } + + #endregion + + #region Toolbar Button Handlers + + /// + /// Expand all tree view items + /// + private void ExpandAll_Click(object sender, RoutedEventArgs e) + { + ExpandCollapseAll(FindingsTreeView, true); + } + + /// + /// Collapse all tree view items + /// + private void CollapseAll_Click(object sender, RoutedEventArgs e) + { + ExpandCollapseAll(FindingsTreeView, false); + } + + /// + /// Open settings - raises SettingsClick so parent can open the same Checkmarx options page as Scan Results. + /// + private void Settings_Click(object sender, RoutedEventArgs e) + { + SettingsClick?.Invoke(this, EventArgs.Empty); + } + + /// + /// Recursively expand or collapse all TreeView items + /// + private void ExpandCollapseAll(ItemsControl items, bool expand) + { + if (items == null) return; + + foreach (object obj in items.Items) + { + ItemsControl childControl = items.ItemContainerGenerator.ContainerFromItem(obj) as ItemsControl; + if (childControl != null) + { + if (childControl is TreeViewItem treeItem) + { + treeItem.IsExpanded = expand; + ExpandCollapseAll(treeItem, expand); + } + } + } + } + + #endregion + + #region Context Menu Handlers + + /// + /// Resolves all related vulnerabilities for the selected node. + /// For OSS: all CVEs for the same package. For IaC/ASCA: all issues on the same line. + /// + private static List ResolveAllRelatedVulnerabilities(VulnerabilityNode node) + { + if (node == null) return null; + var v = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(node.FilePath, node.Line > 0 ? node.Line - 1 : 0); + if (v == null) return null; + + List allVulns = null; + if (v.Scanner == Core.Models.ScannerType.OSS) + allVulns = CxAssistDisplayCoordinator.FindAllVulnerabilitiesForPackage(v); + else if (v.Scanner == Core.Models.ScannerType.IaC || v.Scanner == Core.Models.ScannerType.ASCA) + allVulns = CxAssistDisplayCoordinator.FindAllVulnerabilitiesForLine(v); + + return allVulns ?? new List { v }; + } + + /// + /// Builds a JetBrains-style ScanIssue JSON structure from a list of related vulnerabilities. + /// Groups vulnerabilities under a single scan issue with nested vulnerabilities array + /// (aligned with JetBrains ObjectMapper.writeValueAsString on ScanIssue). + /// + private static object BuildScanIssueForCopy(Core.Models.Vulnerability primary, List allVulns) + { + var locations = primary.Locations != null && primary.Locations.Count > 0 + ? primary.Locations.Select(l => new { line = l.Line, startIndex = l.StartIndex, endIndex = l.EndIndex }).ToArray() + : new[] { new { line = primary.LineNumber, startIndex = primary.StartIndex, endIndex = primary.EndIndex } }; + + string severityStr = primary.Severity.ToString(); + string scanEngine = primary.Scanner.ToString(); + string title = primary.Scanner == Core.Models.ScannerType.OSS + ? (primary.PackageName ?? primary.Title ?? "") + : (primary.Title ?? primary.RuleName ?? primary.Description ?? ""); + + if (allVulns.Count > 1) + { + if (primary.Scanner == Core.Models.ScannerType.IaC) + title = allVulns.Count + CxAssistConstants.MultipleIacIssuesOnLine; + else if (primary.Scanner == Core.Models.ScannerType.ASCA) + title = allVulns.Count + CxAssistConstants.MultipleAscaViolationsOnLine; + } + + var highestSev = allVulns.OrderBy(v => v.Severity).First().Severity.ToString(); + + var vulnerabilities = allVulns.Select(v => new + { + vulnerabilityId = v.Id, + cve = v.CveName, + description = v.Description, + severity = v.Severity.ToString(), + remediationAdvise = v.RemediationAdvice, + fixVersion = v.RecommendedVersion, + actualValue = v.ActualValue, + expectedValue = v.ExpectedValue, + title = v.Scanner == Core.Models.ScannerType.OSS ? (string)null : (v.Title ?? v.RuleName), + similarityId = (string)null + }).ToArray(); + + string fileType = null; + if (primary.Scanner == Core.Models.ScannerType.IaC || primary.Scanner == Core.Models.ScannerType.Containers) + { + try { fileType = System.IO.Path.GetExtension(primary.FilePath)?.TrimStart('.').ToLowerInvariant(); } + catch { /* ignore */ } + } + + return new + { + scanIssueId = (string)null, + severity = highestSev, + title, + description = (string)null, + remediationAdvise = primary.RemediationAdvice, + packageVersion = primary.PackageVersion, + packageManager = primary.PackageManager, + cve = (string)null, + scanEngine, + filePath = primary.FilePath, + imageTag = primary.Scanner == Core.Models.ScannerType.Containers ? primary.PackageVersion : null, + fileType, + secretValue = (string)null, + similarityId = (string)null, + ruleId = primary.RuleName, + problematicLineNumber = (primary.Scanner == Core.Models.ScannerType.IaC || primary.Scanner == Core.Models.ScannerType.ASCA) + ? (int?)primary.LineNumber : null, + locations, + vulnerabilities + }; + } + + /// + /// Copy selected item as JSON to clipboard (aligned with JetBrains: ObjectMapper.writeValueAsString on ScanIssue). + /// Produces a grouped ScanIssue structure with nested vulnerabilities array matching JetBrains format. + /// + private void CopyMenuItem_Click(object sender, RoutedEventArgs e) + { + var node = GetSelectedVulnerability(); + if (node == null) return; + try + { + var allVulns = ResolveAllRelatedVulnerabilities(node); + string json; + if (allVulns != null && allVulns.Count > 0) + { + var primary = allVulns[0]; + var scanIssue = BuildScanIssueForCopy(primary, allVulns); + json = Newtonsoft.Json.JsonConvert.SerializeObject(new[] { scanIssue }, Newtonsoft.Json.Formatting.Indented); + } + else + { + json = Newtonsoft.Json.JsonConvert.SerializeObject(new[] { new { node.Description, node.Severity, node.Scanner, node.Line, node.Column, node.FilePath, node.PackageName, node.PackageVersion } }, Newtonsoft.Json.Formatting.Indented); + } + Clipboard.SetText(json); + ShowStatusBarNotification("Copied to clipboard."); + } + catch + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {CxAssistConstants.FAILED_COPY_CLIPBOARD}"); + } + } + + /// + /// Copy short message to clipboard. For grouped rows (multiple IaC/ASCA on same line, multiple OSS CVEs), + /// includes summary of all related vulnerabilities. For single findings, copies the primary display text. + /// + private void CopyMessage_Click(object sender, RoutedEventArgs e) + { + var node = GetSelectedVulnerability(); + if (node == null) return; + try + { + var allVulns = ResolveAllRelatedVulnerabilities(node); + string message; + if (allVulns != null && allVulns.Count > 1) + { + var lines = new List { node.PrimaryDisplayText }; + for (int i = 0; i < allVulns.Count; i++) + { + var vuln = allVulns[i]; + string title = vuln.Title ?? vuln.RuleName ?? vuln.CveName ?? vuln.Description ?? ""; + string sev = CxAssistConstants.GetRichSeverityName(vuln.Severity); + lines.Add($" {i + 1}. [{sev}] {title}"); + } + message = string.Join("\n", lines); + } + else + { + message = node.PrimaryDisplayText; + } + + if (!string.IsNullOrEmpty(message)) + { + Clipboard.SetText(message); + ShowStatusBarNotification("Message copied to clipboard."); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {CxAssistConstants.FAILED_COPY_CLIPBOARD}"); + } + } + + /// + /// Copy the fix prompt to clipboard (aligned with JetBrains "Copy Fix" context menu action). + /// Builds the scanner-specific remediation prompt and puts it on the clipboard. + /// For grouped rows (multiple IaC/ASCA on same line), all related vulnerabilities are included in the prompt. + /// + private void CopyFixPrompt_Click(object sender, RoutedEventArgs e) + { + var node = GetSelectedVulnerability(); + if (node == null) return; + var v = CxAssistDisplayCoordinator.FindVulnerabilityByLocation(node.FilePath, node.Line > 0 ? node.Line - 1 : 0); + if (v == null) + return; + + List sameLineVulns = null; + if (v.Scanner == Core.Models.ScannerType.IaC || v.Scanner == Core.Models.ScannerType.ASCA) + sameLineVulns = CxAssistDisplayCoordinator.FindAllVulnerabilitiesForLine(v); + + string prompt = Core.Prompts.CxOneAssistFixPrompts.BuildForVulnerability(v, sameLineVulns); + if (!string.IsNullOrEmpty(prompt)) + { + try + { + Clipboard.SetText(prompt); + ShowStatusBarNotification("Fix prompt copied to clipboard. Paste into GitHub Copilot Chat to get remediation steps."); + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {string.Format(CxAssistConstants.FIX_PROMPT_COPIED, v.Title ?? v.Description ?? "unknown")}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] {CxAssistConstants.FAILED_COPY_CLIPBOARD}"); + } + } + } + + /// + /// Shows a brief notification in the VS status bar (aligned with JetBrains Utils.showNotification for copy actions). + /// + private static void ShowStatusBarNotification(string message) + { + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + var dte = Microsoft.VisualStudio.Shell.ServiceProvider.GlobalProvider.GetService(typeof(DTE)) as DTE; + if (dte != null) + dte.StatusBar.Text = message; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CxAssist StatusBar notification error: {ex.Message}"); + } + } + + /// + /// Ignore selected finding (placeholder for now) + /// + private void IgnoreMenuItem_Click(object sender, RoutedEventArgs e) + { + var selectedItem = FindingsTreeView.SelectedItem; + if (selectedItem == null) return; + + string itemName = ""; + if (selectedItem is FileNode fileNode) + { + itemName = fileNode.FileName; + } + else if (selectedItem is VulnerabilityNode vulnNode) + { + itemName = vulnNode.DisplayText; + } + + MessageBox.Show($"Ignore functionality coming soon!\n\nSelected: {itemName}", + "Ignore Finding", MessageBoxButton.OK, MessageBoxImage.Information); + } + + #endregion + } + + /// + /// Converts IsDarkTheme (bool) to opacity for file icons: dark theme uses 0.88 for a softer look, light theme uses 1.0. + /// + internal sealed class DarkThemeToFileIconOpacityConverter : IValueConverter + { + private const double DarkThemeOpacity = 0.88; + private const double LightThemeOpacity = 1.0; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool isDark) + return isDark ? DarkThemeOpacity : LightThemeOpacity; + return LightThemeOpacity; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => throw new NotImplementedException(); + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsWindow.cs b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsWindow.cs new file mode 100644 index 00000000..8822f339 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsWindow.cs @@ -0,0 +1,40 @@ +using System; +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.Shell; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow +{ + /// + /// This class implements the tool window exposed by this package and hosts a user control. + /// + /// + /// In Visual Studio tool windows are composed of a frame (implemented by the shell) and a pane, + /// usually implemented by the package implementer. + /// + /// This class derives from the ToolWindowPane class provided from the MPF in order to use its + /// implementation of the IVsUIElementPane interface. + /// + /// + [Guid("8F3E8B6A-1234-4567-89AB-CDEF01234567")] + public class CxAssistFindingsWindow : ToolWindowPane + { + /// + /// Initializes a new instance of the class. + /// + public CxAssistFindingsWindow() : base(null) + { + this.Caption = "Checkmarx Findings"; + + // This is the user control hosted by the tool window; Note that, even if this class implements IDisposable, + // we are not calling Dispose on this object. This is because ToolWindowPane calls Dispose on + // the object returned by the Content property. + this.Content = new CxAssistFindingsControl(); + } + + /// + /// Get the control hosted in this tool window + /// + public CxAssistFindingsControl FindingsControl => this.Content as CxAssistFindingsControl; + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/FindingsTreeNode.cs b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/FindingsTreeNode.cs new file mode 100644 index 00000000..77e44626 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/FindingsTreeNode.cs @@ -0,0 +1,223 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Windows.Media; +using ast_visual_studio_extension.CxExtension.CxAssist.Core; +using ast_visual_studio_extension.CxExtension.CxAssist.Core.Models; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.UI.FindingsWindow +{ + /// + /// Base class for tree nodes in the Findings window + /// + public abstract class FindingsTreeNode : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + /// + /// Represents a file node with vulnerability count badges + /// + public class FileNode : FindingsTreeNode + { + private string _fileName; + private string _filePath; + private ImageSource _fileIcon; + private ObservableCollection _severityCounts; + private ObservableCollection _vulnerabilities; + + public string FileName + { + get => _fileName; + set { _fileName = value; OnPropertyChanged(nameof(FileName)); } + } + + public string FilePath + { + get => _filePath; + set { _filePath = value; OnPropertyChanged(nameof(FilePath)); } + } + + public ImageSource FileIcon + { + get => _fileIcon; + set { _fileIcon = value; OnPropertyChanged(nameof(FileIcon)); } + } + + public ObservableCollection SeverityCounts + { + get => _severityCounts; + set { _severityCounts = value; OnPropertyChanged(nameof(SeverityCounts)); } + } + + public ObservableCollection Vulnerabilities + { + get => _vulnerabilities; + set { _vulnerabilities = value; OnPropertyChanged(nameof(Vulnerabilities)); } + } + + public FileNode() + { + SeverityCounts = new ObservableCollection(); + Vulnerabilities = new ObservableCollection(); + } + } + + /// + /// Represents a severity count badge (e.g., "🔴 2") + /// + public class SeverityCount : INotifyPropertyChanged + { + private string _severity; + private int _count; + private ImageSource _icon; + + public string Severity + { + get => _severity; + set { _severity = value; OnPropertyChanged(nameof(Severity)); } + } + + public int Count + { + get => _count; + set { _count = value; OnPropertyChanged(nameof(Count)); } + } + + public ImageSource Icon + { + get => _icon; + set { _icon = value; OnPropertyChanged(nameof(Icon)); } + } + + public event PropertyChangedEventHandler PropertyChanged; + + protected void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + /// + /// Represents a vulnerability node with severity icon and details + /// + public class VulnerabilityNode : FindingsTreeNode + { + private string _severity; + private ImageSource _severityIcon; + private string _description; + private string _packageName; + private string _packageVersion; + private int _line; + private int _column; + private string _filePath; + private ScannerType _scanner; + + public string Severity + { + get => _severity; + set { _severity = value; OnPropertyChanged(nameof(Severity)); } + } + + public ImageSource SeverityIcon + { + get => _severityIcon; + set { _severityIcon = value; OnPropertyChanged(nameof(SeverityIcon)); } + } + + public string Description + { + get => _description; + set { _description = value; OnPropertyChanged(nameof(Description)); } + } + + public string PackageName + { + get => _packageName; + set { _packageName = value; OnPropertyChanged(nameof(PackageName)); } + } + + public string PackageVersion + { + get => _packageVersion; + set { _packageVersion = value; OnPropertyChanged(nameof(PackageVersion)); } + } + + public int Line + { + get => _line; + set { _line = value; OnPropertyChanged(nameof(Line)); } + } + + public int Column + { + get => _column; + set { _column = value; OnPropertyChanged(nameof(Column)); } + } + + public string FilePath + { + get => _filePath; + set { _filePath = value; OnPropertyChanged(nameof(FilePath)); } + } + + /// Scanner that produced this finding (OSS, ASCA, Secrets, etc.). Used for reference-style primary text. + public ScannerType Scanner + { + get => _scanner; + set { _scanner = value; OnPropertyChanged(nameof(Scanner)); } + } + + /// + /// Full formatted display text: primary + " " + agent name + " [Ln N, Col M]" (used for copy, tooltips, message boxes). + /// + public string DisplayText + { + get => $"{PrimaryDisplayText} {CxAssistConstants.DisplayName} [Ln {Line}, Col {Column}]"; + } + + /// Primary text (bright), formatted by scanner like reference IssueTreeRenderer: ASCA/IaC=title, OSS=severity-risk package: name@version, Secrets=severity-risk secret: title, Containers=severity-risk container image: title. Grouped-by-line rows show only the summary (e.g. "N OSS issues detected on this line"). + public string PrimaryDisplayText + { + get + { + string title = !string.IsNullOrEmpty(Description) ? Description : ""; + // Grouped-by-line summary rows: show only the message (e.g. "3 OSS issues detected on this line") + if (title.Contains(" detected on this line") || title.Contains(" violations detected on this line")) + return title.TrimEnd(); + switch (Scanner) + { + case ScannerType.ASCA: + return title + (string.IsNullOrEmpty(title) ? "" : " "); + case ScannerType.OSS: + { + // Prefer title then PackageName; strip (CVE-...) from display for cleaner UI + string name = !string.IsNullOrEmpty(title) ? title : (PackageName ?? ""); + name = CxAssistConstants.StripCveFromDisplayName(name); + string version = !string.IsNullOrEmpty(PackageVersion) ? $"@{PackageVersion}" : ""; + return $"{Severity}-risk package: {name}{version}"; + } + case ScannerType.Secrets: + return $"{Severity}-risk secret: {title}"; + case ScannerType.Containers: + return $"{Severity}-risk container image: {title}"; + case ScannerType.IaC: + return title + (string.IsNullOrEmpty(title) ? "" : " "); + default: + return title; + } + } + } + + /// Secondary text (darker grey): agent name + location e.g. "Checkmarx One Assist [Ln 14, Col 4]" for reference-style UI. + public string SecondaryDisplayText + { + get => $"{CxAssistConstants.DisplayName} [Ln {Line}, Col {Column}]"; + } + } +} + diff --git a/ast-visual-studio-extension/CxExtension/CxWindowControl.xaml b/ast-visual-studio-extension/CxExtension/CxWindowControl.xaml index c907b0f9..5144da5a 100644 --- a/ast-visual-studio-extension/CxExtension/CxWindowControl.xaml +++ b/ast-visual-studio-extension/CxExtension/CxWindowControl.xaml @@ -1,4 +1,4 @@ - + Name="CxWindow" + Background="{DynamicResource {x:Static vsfx:VsBrushes.ToolWindowBackgroundKey}}"> @@ -24,32 +26,61 @@ + + + + + + + + + + - + + + - + @@ -698,22 +771,16 @@ - - + + - - - + + + - + @@ -806,7 +873,46 @@ + + + + + + + + + +