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..2b1aa5f9 --- /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("9", 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("41", 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("0", 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..03ea0f75 --- /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 by 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("Markdown", 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..ae32ca96 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/Commands/ErrorListContextMenuCommand.cs @@ -0,0 +1,181 @@ +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), v => CxAssistConstants.ShouldShowIgnoreAll(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(); + var v = GetSelectedCxAssistVulnerability(); + if (v == null) return; + string label = CxAssistConstants.GetIgnoreThisLabel(v.Scanner); + var result = MessageBox.Show( + $"{label}?\n{v.Title ?? v.Description ?? v.Id}", + CxAssistConstants.DisplayName, + MessageBoxButton.YesNo, + MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + MessageBox.Show(CxAssistConstants.GetIgnoreThisSuccessMessage(v.Scanner), CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + }); + } + + private void OnIgnoreAll(object sender, EventArgs e) + { + ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var v = GetSelectedCxAssistVulnerability(); + if (v == null) return; + string label = CxAssistConstants.GetIgnoreAllLabel(v.Scanner); + var result = MessageBox.Show( + $"{label}?\n{v.Description}", + CxAssistConstants.DisplayName, + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + if (result == MessageBoxResult.Yes) + MessageBox.Show(CxAssistConstants.GetIgnoreAllSuccessMessage(v.Scanner), 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..07457423 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/AssistIconLoader.cs @@ -0,0 +1,270 @@ +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"; + + /// 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) + { + var img = LoadSeveritySvgIcon(severity.ToString()); + if (img != null) 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..aa4a1f53 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CopilotIntegration.cs @@ -0,0 +1,125 @@ +using System; +using System.Windows; +using System.Windows.Threading; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using EnvDTE; +using EnvDTE80; + +namespace ast_visual_studio_extension.CxExtension.CxAssist.Core +{ + /// + /// Reusable: sends a prompt to GitHub Copilot Chat (open via Ctrl+\ then C, new chat, paste, submit). + /// Used by for Fix and View details. Message text comes from . + /// + internal static class CopilotIntegration + { + private const int PasteAndSubmitDelayMs = 1000; + + /// Sends the prompt to Copilot: clipboard, open chat, new chat, paste and submit. Returns true if clipboard was set. + public static bool SendPromptToCopilot(string prompt, string clipboardFallbackMessage) + { + if (string.IsNullOrWhiteSpace(prompt)) + return false; + + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + Clipboard.SetText(prompt); + + bool opened = TryOpenCopilotChat(); + if (opened) + { + // Schedule: ensure new chat, then paste + submit after the chat UI is ready. + var timer = new DispatcherTimer(DispatcherPriority.ApplicationIdle) + { + Interval = TimeSpan.FromMilliseconds(PasteAndSubmitDelayMs) + }; + timer.Tick += (s, e) => + { + timer.Stop(); + try + { + StartNewChatThenPasteAndSubmit(); + MessageBox.Show(CxAssistConstants.CopilotPromptSentMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CopilotIntegration.PasteAndSubmitPrompt"); + MessageBox.Show(CxAssistConstants.CopilotPasteFailedMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + }; + timer.Start(); + } + else + { + MessageBox.Show(CxAssistConstants.CopilotOpenInstructionsMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + + return true; + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CopilotIntegration.SendPromptToCopilot"); + try + { + Clipboard.SetText(prompt); + MessageBox.Show(clipboardFallbackMessage ?? CxAssistConstants.CopilotGenericFallbackMessage, CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + return true; + } + catch + { + return false; + } + } + } + + private static void StartNewChatThenPasteAndSubmit() + { + ThreadHelper.ThrowIfNotOnUIThread(); + // Start new conversation (Copilot chat slash command) + System.Windows.Forms.SendKeys.SendWait("/new{ENTER}"); + System.Threading.Thread.Sleep(400); + // Paste prompt and submit + System.Windows.Forms.SendKeys.SendWait("^v{ENTER}"); + } + + private static bool TryOpenCopilotChat() + { + try + { + ThreadHelper.ThrowIfNotOnUIThread(); + + // PR #304: default keyboard shortcut (Ctrl+\ then C) to open GitHub Copilot Chat + try + { + System.Windows.Forms.SendKeys.SendWait("^\\c"); + return true; + } + catch + { + // SendKeys failed, try DTE commands + } + + var dte = Package.GetGlobalService(typeof(DTE)) as DTE2; + if (dte == null) return false; + + foreach (var commandName in new[] { "Edit.Copilot.Open", "View.CopilotChat", "GitHub.Copilot.Chat.Show" }) + { + try + { + dte.ExecuteCommand(commandName); + return true; + } + catch { } + } + } + catch (Exception ex) + { + CxAssistErrorHandler.LogAndSwallow(ex, "CopilotIntegration.TryOpenCopilotChat"); + } + + return false; + } + } +} 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..f915de4f --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistConstants.cs @@ -0,0 +1,204 @@ +using System; +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 + { + /// 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"; + + /// 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 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 = "Copilot Chat was opened but the prompt could not be sent automatically. The prompt is on your clipboard—click in the chat box, paste (Ctrl+V), then press Enter."; + public const string CopilotOpenInstructionsMessage = "Prompt copied to clipboard. Open GitHub Copilot Chat (View → GitHub Copilot Chat, or press Alt+Ctrl+Enter), then paste (Ctrl+V) and press Enter to get assistance."; + 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..bad2e966 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistCopilotActions.cs @@ -0,0 +1,56 @@ +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. + /// + public static void SendFixWithAssist(Vulnerability v) + { + if (v == null) return; + string prompt = CxOneAssistFixPrompts.BuildForVulnerability(v); + if (string.IsNullOrEmpty(prompt)) + { + ShowNoPromptMessage(v?.Title ?? v?.Description ?? "—", isFix: true); + return; + } + CopilotIntegration.SendPromptToCopilot(prompt, CxAssistConstants.CopilotFixFallbackMessage); + } + + /// + /// 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. + /// + /// The vulnerability to explain. + /// Optional. For OSS, pass other vulnerabilities on the same line to include CVE list in the prompt. + public static void SendViewDetails(Vulnerability v, IReadOnlyList sameLineVulns = null) + { + if (v == null) return; + string prompt = ViewDetailsPrompts.BuildForVulnerability(v, sameLineVulns); + if (string.IsNullOrEmpty(prompt)) + { + ShowNoPromptMessage($"{v?.Title ?? ""}\n{v?.Description ?? ""}\nSeverity: {v?.Severity}", isFix: false); + return; + } + CopilotIntegration.SendPromptToCopilot(prompt, CxAssistConstants.CopilotViewDetailsFallbackMessage); + } + + 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..6cf09f65 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistDisplayCoordinator.cs @@ -0,0 +1,249 @@ +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); + + /// + /// 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); + } + } + + /// + /// 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) + { + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] DisplayCoordinator: buffer is null"); + return; + } + + var list = vulnerabilities ?? new List(); + + // 1. Update gutter + var glyphTagger = CxAssistErrorHandler.TryGet(() => CxAssistGlyphTaggerProvider.GetTaggerForBuffer(buffer), "Coordinator.GetGlyphTagger", null); + if (glyphTagger != null) + CxAssistErrorHandler.TryRun(() => glyphTagger.UpdateVulnerabilities(list), "Coordinator.GlyphTagger.UpdateVulnerabilities"); + else + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] DisplayCoordinator: glyph tagger not found for buffer"); + + // 2. Update underline + var errorTagger = CxAssistErrorHandler.TryGet(() => CxAssistErrorTaggerProvider.GetTaggerForBuffer(buffer), "Coordinator.GetErrorTagger", null); + if (errorTagger != null) + CxAssistErrorHandler.TryRun(() => errorTagger.UpdateVulnerabilities(list), "Coordinator.ErrorTagger.UpdateVulnerabilities"); + else + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] DisplayCoordinator: error tagger not found for buffer"); + + // 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"); + + System.Diagnostics.Debug.WriteLine($"[{CxAssistConstants.LogCategory}] DisplayCoordinator: updated gutter, underline, and per-file findings ({list.Count} for file)"); + } + + /// + /// 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); + } + + /// + /// 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..f0fb98bb --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/CxAssistErrorListSync.cs @@ -0,0 +1,325 @@ +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); + } + } + } + + /// + /// 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/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..daef8c3c --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/FindingsTreeBuilder.cs @@ -0,0 +1,203 @@ +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; + var fileName = Path.GetFileName(filePath); + if (string.IsNullOrEmpty(fileName)) fileName = filePath; + + var fileIcon = loadFileIcon?.Invoke(filePath); + var fileNode = new FileNode + { + FileName = fileName, + FilePath = filePath, + FileIcon = fileIcon + }; + + 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 → show highest-severity detail only (not "N ASCA violations...") + foreach (var lineGroup in ascaVulns.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.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..a90a6468 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTagger.cs @@ -0,0 +1,175 @@ +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 mostSevere = GetMostSevereVulnerability(vulnerabilities); + if (mostSevere != null) + { + var line = snapshot.GetLineFromLineNumber(lineNumber); + var lineSpan = new SnapshotSpan(snapshot, line.Start, line.Length); + + // Tooltip shows only the severity that matches the icon (precedence / most severe on line) + 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)); + } + + /// + /// 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; + + // Order by severity: Critical > High > Medium > Low > Info + return vulnerabilities + .OrderByDescending(v => GetSeverityPriority(v.Severity)) + .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..2346389e --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistGlyphTaggerProvider.cs @@ -0,0 +1,151 @@ +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; + +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; + } + + /// + /// 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..9aa71bdb --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/GutterIcons/CxAssistMockDataViewCreationListener.cs @@ -0,0 +1,191 @@ +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.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) + { + 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; + + List vulnerabilities = GetMockVulnerabilitiesForFile(filePath); + if (vulnerabilities == null || vulnerabilities.Count == 0) + return; + + System.Diagnostics.Debug.WriteLine($"CxAssist: Mock data file opened ({filePath}), will apply {vulnerabilities.Count} findings"); + + 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) + { + System.Diagnostics.Debug.WriteLine("CxAssist: Mock data listener – taggers not found"); + return; + } + + vulnerabilities = GetMockVulnerabilitiesForFile(filePath); + if (vulnerabilities == null || vulnerabilities.Count == 0) + return; + + CxAssistDisplayCoordinator.UpdateFindings(buffer, vulnerabilities, filePath); + System.Diagnostics.Debug.WriteLine($"CxAssist: Updated gutter, underline, findings for {filePath} ({vulnerabilities.Count} items)"); + }); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"CxAssist: Mock data listener error: {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..ec532654 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistErrorTagger.cs @@ -0,0 +1,228 @@ +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) + { + 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 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 (JetBrains: one range highlighter per location line). + /// + 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 new SnapshotSpan(snapshot, line.Start, line.Length); + } + return new SnapshotSpan(snapshot, line.Start, line.Length); + } + + // 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 new SnapshotSpan(snapshot, line.Start, line.Length); + } + + /// + /// 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..9ceef346 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickFixActions.cs @@ -0,0 +1,263 @@ +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) + { + if (_vulnerability == null) return; + var v = _vulnerability; + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => + { + try + { + string label = CxAssistConstants.GetIgnoreThisLabel(v.Scanner); + var result = MessageBox.Show( + $"{label}?\n{v.Title ?? v.Description ?? v.Id}", + CxAssistConstants.DisplayName, + MessageBoxButton.YesNo, + MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + MessageBox.Show(CxAssistConstants.GetIgnoreThisSuccessMessage(v.Scanner), 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) + { + if (_vulnerability == null) return; + var v = _vulnerability; + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(new Action(() => + { + try + { + string label = CxAssistConstants.GetIgnoreAllLabel(v.Scanner); + var result = MessageBox.Show( + $"{label}?\n{v.Description}", + CxAssistConstants.DisplayName, + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + if (result == MessageBoxResult.Yes) + MessageBox.Show(CxAssistConstants.GetIgnoreAllSuccessMessage(v.Scanner), 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..5e2bc74e --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistQuickInfoSource.cs @@ -0,0 +1,993 @@ +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($"Ignore all of this type:\n{v?.Title ?? v?.Description ?? "—"}\n(Scanner: {v?.Scanner})", CxAssistConstants.DisplayName)); + } + + internal static void RunFixWithAssist(Vulnerability v) + { + RunOnUiThread(() => CxAssistCopilotActions.SendFixWithAssist(v)); + } + + internal static void RunViewDetails(Vulnerability v) + { + RunOnUiThread(() => CxAssistCopilotActions.SendViewDetails(v)); + } + + internal static void RunIgnoreVulnerability(Vulnerability v) + { + RunOnUiThread(() => MessageBox.Show($"Ignore vulnerability:\n{v?.Title ?? v?.Description ?? "—"}", CxAssistConstants.DisplayName)); + } + + 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..a11c29f0 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Markers/CxAssistSuggestedActionsSource.cs @@ -0,0 +1,89 @@ +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(); + + // Use first vulnerability for Quick Fix actions (same as hover popup / context menu when multiple on line) + 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..2cf781fb --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/CxOneAssistFixPrompts.cs @@ -0,0 +1,133 @@ +using System; +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 JetBrains CxOneAssistFixPrompts). + /// Used to generate a prompt that is sent to GitHub Copilot for automated remediation. + /// + internal static class CxOneAssistFixPrompts + { + private const string AgentName = "Checkmarx One Assist"; + + public static string BuildForVulnerability(Vulnerability v) + { + 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); + 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); + 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) + { + var sb = new StringBuilder(); + sb.Append("You are the ").Append(AgentName).Append(".\n\n") + .Append("A security issue has been detected in `").Append(packageName).Append("@").Append(packageVersion).Append("` (package manager: `").Append(packageManager).Append("`).\n") + .Append("**Severity:** `").Append(severity).Append("`\n") + .Append("Your task is to remediate the issue **completely and autonomously** using the internal PackageRemediation tool in Checkmarx MCP. Follow the exact instructions in `fix_instructions` - no assumptions or manual interaction allowed.\n\n"); + sb.Append("Step 1. ANALYSIS (AUTOMATED):\n\n") + .Append("Determine the issue type:\n") + .Append("- If `status` is one of: `Critical`, `High`, `Medium`, `Low`, `Info`, set: `issueType = \"CVE\"`\n") + .Append("- If `status = \"Malicious\"`, set: `issueType = \"malicious\"`\n\n") + .Append("Call the internal PackageRemediation tool with:\n\n") + .Append("```json\n{\n \"packageName\": \"").Append(packageName).Append("\",\n \"packageVersion\": \"").Append(packageVersion).Append("\",\n \"packageManager\": \"").Append(packageManager).Append("\",\n \"issueType\": \"{determined issueType}\"\n}\n```\n\n"); + sb.Append("Parse the response and extract the `fix_instructions` field. Then execute each line in order. Track modified files, note changes, and run verification (build/test).\n\n"); + sb.Append("Step 2. OUTPUT: Prefix all output with: `").Append(AgentName).Append(" -`\n\n"); + sb.Append("✓ **Remediation Summary**\n\nIf all tasks succeeded: \"Remediation completed for ").Append(packageName).Append("@").Append(packageVersion).Append("\". If failed: \"Remediation failed for ").Append(packageName).Append("@").Append(packageVersion).Append("\".\n\n"); + sb.Append("CONSTRAINTS: Do not prompt the user. Only execute what's in `fix_instructions`. Insert TODO comments for unresolved issues.\n"); + return sb.ToString(); + } + + public static string BuildSecretRemediationPrompt(string title, string description, string severity) + { + var sb = new StringBuilder(); + sb.Append("A secret has been detected: \"").Append(title).Append("\" \n").Append(description ?? "").Append("\n\n"); + sb.Append("You are the `").Append(AgentName).Append("`.\n\n") + .Append("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.\n\n"); + sb.Append("Step 1. SEVERITY: `").Append(severity ?? "").Append("` — Critical: valid secret, immediate remediation. High: treat as sensitive. Medium: likely invalid, still remove.\n\n"); + sb.Append("Step 2. Call the internal `codeRemediation` Checkmarx MCP tool with type \"secret\", sub_type \"").Append(title).Append("\". Apply remediation_steps. Replace secret with environment variable or vault reference.\n\n"); + sb.Append("Step 3. OUTPUT: Prefix with `").Append(AgentName).Append(" -`. Provide Secret Remediation Summary, Files Modified, Remediation Actions Taken, Next Steps, Best Practices. CONSTRAINTS: Do NOT expose real secrets. Follow MCP response.\n"); + return sb.ToString(); + } + + public static string BuildContainersRemediationPrompt(string fileType, string imageName, string imageTag, string severity) + { + var sb = new StringBuilder(); + sb.Append("You are the ").Append(AgentName).Append(".\n\n") + .Append("A container security issue has been detected in `").Append(fileType).Append("` with image `").Append(imageName).Append(":").Append(imageTag).Append("`.\n") + .Append("**Severity:** `").Append(severity).Append("`\n") + .Append("Your task is to remediate using the internal imageRemediation tool. Follow `fix_instructions` exactly.\n\n"); + sb.Append("Step 1. Call imageRemediation with fileType, imageName, imageTag, severity. Parse `fix_instructions`.\n\n"); + sb.Append("Step 2. Execute each line. Track modified files (Dockerfile, docker-compose, values.yaml, etc.).\n\n"); + sb.Append("Step 3. OUTPUT: Prefix with `").Append(AgentName).Append(" -`. Remediation Summary. If succeeded: \"Remediation completed for ").Append(imageName).Append(":").Append(imageTag).Append("\". CONSTRAINTS: Do not prompt user. Follow fix_instructions only.\n"); + return sb.ToString(); + } + + public static string BuildIACRemediationPrompt(string title, string description, string severity, string fileType, string expectedValue, string actualValue, int? problematicLineNumber) + { + var lineNum = problematicLineNumber.HasValue ? (problematicLineNumber.Value).ToString() : "[unknown]"; + var sb = new StringBuilder(); + sb.Append("You are the ").Append(AgentName).Append(".\n\n"); + sb.Append("An IaC security issue has been detected.\n\n**Issue:** `").Append(title).Append("`\n**Severity:** `").Append(severity).Append("`\n**File Type:** `").Append(fileType).Append("`\n**Description:** ").Append(description).Append("\n**Expected Value:** ").Append(expectedValue).Append("\n**Actual Value:** ").Append(actualValue).Append("\n**Problematic Line:** ").Append(lineNum).Append("\n\n"); + sb.Append("Your task is to remediate using the internal codeRemediation tool (type \"iac\"). Apply **only** to the code at line ").Append(lineNum).Append(".\n\n"); + sb.Append("Step 1. Call codeRemediation with type \"iac\", metadata title/description/remediationAdvice. Step 2. Execute remediation_steps in order. Step 3. OUTPUT: Prefix with `").Append(AgentName).Append(" -`. Summary. CONSTRAINTS: Only modify the problematic line segment.\n"); + return sb.ToString(); + } + + public static string BuildASCARemediationPrompt(string ruleName, string description, string severity, string remediationAdvice, int? problematicLineNumber) + { + var lineNum = problematicLineNumber.HasValue ? (problematicLineNumber.Value).ToString() : "[unknown]"; + var sb = new StringBuilder(); + sb.Append("You are the ").Append(AgentName).Append(".\n\n") + .Append("A secure coding issue has been detected.\n\n**Rule:** `").Append(ruleName).Append("` \n**Severity:** `").Append(severity).Append("` \n**Description:** ").Append(description).Append(" \n**Recommended Fix:** ").Append(remediationAdvice).Append(" \n**Problematic Line:** ").Append(lineNum).Append("\n\n"); + sb.Append("Remediate using the internal codeRemediation tool (type \"sast\"). Apply fix **only** to the code at line ").Append(lineNum).Append(".\n\n"); + sb.Append("Step 1. Call codeRemediation with type \"sast\", metadata ruleID/description/remediationAdvice. Step 2. Execute remediation_steps. Step 3. OUTPUT: Prefix with `").Append(AgentName).Append(" -`. Remediation Summary. CONSTRAINTS: Only modify the identified line segment.\n"); + 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..c9a9532b --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/Core/Prompts/ViewDetailsPrompts.cs @@ -0,0 +1,139 @@ +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 JetBrains ViewDetailsPrompts). + /// Used to generate a prompt sent to GitHub Copilot to explain the finding without changing code. + /// + internal static class ViewDetailsPrompts + { + private const string AgentName = "Checkmarx One Assist"; + + /// Builds View Details prompt for the given vulnerability. For OSS, pass all vulns on same line for CVE list if available. + 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 ?? ""); + case ScannerType.ASCA: + return BuildASCAExplanationPrompt( + v.RuleName ?? v.Title ?? "", + v.Description ?? "", + CxAssistConstants.GetRichSeverityName(v.Severity)); + 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) + { + var sb = new StringBuilder(); + sb.Append("You are the `").Append(AgentName).Append("`.\n\n") + .Append("Your task is to **analyze and explain** the security issue affecting the package `").Append(packageName).Append("@").Append(version).Append("` with status: `").Append(status).Append("`.\n\n"); + sb.Append("### Important Instructions:\n- **Do not change anything in the code. Just explain the risks and remediation steps.**\n- **Never include references from Checkmarx competitors.**\n\n"); + sb.Append("### Package Overview\n\n- **Package:** `").Append(packageName).Append("`\n- **Version:** `").Append(version).Append("`\n- **Status:** `").Append(status).Append("`\n\n"); + if (string.Equals(status, "Malicious", StringComparison.OrdinalIgnoreCase)) + { + sb.Append("### Malicious Package Detected\n\nThis package has been flagged as **malicious**. **Never install or use this package.** Explain what malicious packages typically do and recommend immediate removal and replacement.\n\n"); + } + else + { + sb.Append("### Known Vulnerabilities\n\n"); + if (vulnerabilities != null && vulnerabilities.Count > 0) + { + foreach (var vuln in vulnerabilities.Take(20)) + sb.Append("- ").Append(vuln.CveName ?? vuln.Id ?? "CVE").Append(" — ").Append(CxAssistConstants.GetRichSeverityName(vuln.Severity)).Append(": ").Append(vuln.Description ?? "").Append("\n"); + } + else + { + sb.Append("No CVEs were provided. Verify if this is expected for status `").Append(status).Append("`.\n\n"); + } + } + sb.Append("### Remediation Guidance\n\nOffer actionable advice: remove, upgrade, or replace the package; recommend safer alternatives; suggest SCA in CI/CD and version pinning.\n\n"); + sb.Append("### Summary\n\nConclude with overall risk, immediate remediation steps, and output in Markdown (developer-friendly, concise).\n"); + return sb.ToString(); + } + + public static string BuildSecretsExplanationPrompt(string title, string description, string severity) + { + var sb = new StringBuilder(); + sb.Append("You are the `").Append(AgentName).Append("`.\n\n") + .Append("A potential secret has been detected: **\"").Append(title).Append("\"** \nSeverity: **").Append(severity).Append("**\n\n"); + sb.Append("### Important Instruction:\n**Do not change any code. Just explain the risk, validation level, and recommended actions.**\n\n"); + sb.Append("### Secret Overview\n\n- **Secret Name:** `").Append(title).Append("`\n- **Severity:** `").Append(severity).Append("`\n- **Details:** ").Append(description ?? "").Append("\n\n"); + sb.Append("### Risk by Severity\n- **Critical**: Validated as active; immediate remediation.\n- **High**: Unknown validation; treat as potentially live.\n- **Medium**: Likely invalid/mock; still remove.\n\n"); + sb.Append("### Why This Matters\nHardcoded secrets risk leakage, unauthorized access, exploitation. Recommend: rotate if live, move to env/vault, audit history, add secret scanning in CI/CD.\n\n"); + sb.Append("### Output\nUse Markdown. Be factual and helpful. Do not edit code.\n"); + return sb.ToString(); + } + + public static string BuildContainersExplanationPrompt(string fileType, string imageName, string imageTag, string severity) + { + var sb = new StringBuilder(); + sb.Append("You are the `").Append(AgentName).Append("`.\n\n") + .Append("Your task is to **analyze and explain** the container security issue affecting `").Append(fileType).Append("` with image `").Append(imageName).Append(":").Append(imageTag).Append("` and severity: `").Append(severity).Append("`.\n\n"); + sb.Append("### Important Instructions:\n**Do not change anything in the code. Just explain the risks and remediation steps.**\n\n"); + sb.Append("### Container Overview\n- **File Type:** `").Append(fileType).Append("`\n- **Image:** `").Append(imageName).Append(":").Append(imageTag).Append("`\n- **Severity:** `").Append(severity).Append("`\n\n"); + sb.Append("Explain container security issues (outdated base images, CVEs, root user, missing patches). Offer remediation guidance and summary in Markdown.\n"); + return sb.ToString(); + } + + public static string BuildIACExplanationPrompt(string title, string description, string severity, string fileType, string expectedValue, string actualValue) + { + var sb = new StringBuilder(); + sb.Append("You are the `").Append(AgentName).Append("`.\n\n") + .Append("Your task is to **analyze and explain** the IaC security issue: **").Append(title).Append("** with severity: `").Append(severity).Append("`.\n\n"); + sb.Append("### Important Instructions:\n**Do not change configuration. Just explain risks and remediation.**\n\n"); + sb.Append("### IaC Overview\n- **Issue:** `").Append(title).Append("`\n- **File Type:** `").Append(fileType).Append("`\n- **Severity:** `").Append(severity).Append("`\n- **Description:** ").Append(description).Append("\n- **Expected:** `").Append(expectedValue).Append("`\n- **Actual:** `").Append(actualValue).Append("`\n\n"); + sb.Append("Explain security risks (overly permissive access, exposed credentials, insecure config). Offer remediation and preventative measures. Output in Markdown.\n"); + return sb.ToString(); + } + + public static string BuildASCAExplanationPrompt(string ruleName, string description, string severity) + { + var sb = new StringBuilder(); + sb.Append("You are the ").Append(AgentName).Append(" providing detailed security explanations.\n\n") + .Append("**Rule:** `").Append(ruleName).Append("` \n**Severity:** `").Append(severity).Append("` \n**Description:** ").Append(description).Append("\n\n"); + sb.Append("Provide a comprehensive explanation: security issue overview, why it matters (attacks, impact), best practices, secure alternatives, and additional resources. Use clear Markdown.\n"); + return sb.ToString(); + } + } +} 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..31228ae9 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml @@ -0,0 +1,479 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..2c7c33d6 --- /dev/null +++ b/ast-visual-studio-extension/CxExtension/CxAssist/UI/FindingsWindow/CxAssistFindingsControl.xaml.cs @@ -0,0 +1,775 @@ +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; + + // Load filter icons and subscribe to coordinator (reference ISSUE_TOPIC-like: window stays in sync when issues change) + 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.LoadSvgIcon(theme, "expandall"); + CollapseAllIcon.Source = AssistIconLoader.LoadSvgIcon(theme, "collapseall"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error loading filter icons: {ex.Message}"); + } + } + + /// + /// 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 double-click on tree item to navigate to file location + /// + 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; + + 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(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(); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error navigating to vulnerability: {ex.Message}"); + } + } + + #region Context Menu Handlers + + /// + /// 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; // Hide context menu when right-click is on file node + return; + } + // Set Ignore menu labels and visibility based on scanner (Ignore all only for OSS and Container) + if (treeViewItem?.DataContext is VulnerabilityNode node && IgnoreThisMenuItem != null && IgnoreAllMenuItem != null) + { + IgnoreThisMenuItem.Header = CxAssistConstants.GetIgnoreThisLabel(node.Scanner); + IgnoreAllMenuItem.Header = CxAssistConstants.GetIgnoreAllLabel(node.Scanner); + IgnoreAllMenuItem.Visibility = CxAssistConstants.ShouldShowIgnoreAll(node.Scanner) ? Visibility.Visible : Visibility.Collapsed; + } + } + + 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) + { + var vulnerability = GetSelectedVulnerability(); + if (vulnerability != null) + { + string label = CxAssistConstants.GetIgnoreThisLabel(vulnerability.Scanner); + var result = MessageBox.Show($"{label}?\n{vulnerability.DisplayText}", + CxAssistConstants.DisplayName, MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result == MessageBoxResult.Yes) + { + // TODO: Implement ignore logic + MessageBox.Show(CxAssistConstants.GetIgnoreThisSuccessMessage(vulnerability.Scanner), CxAssistConstants.DisplayName, MessageBoxButton.OK, MessageBoxImage.Information); + } + } + } + + private void IgnoreAll_Click(object sender, RoutedEventArgs e) + { + var vulnerability = GetSelectedVulnerability(); + if (vulnerability != null) + { + string label = CxAssistConstants.GetIgnoreAllLabel(vulnerability.Scanner); + var result = MessageBox.Show($"{label}?\n{vulnerability.Description}", + CxAssistConstants.DisplayName, MessageBoxButton.YesNo, MessageBoxImage.Warning); + if (result == MessageBoxResult.Yes) + { + // TODO: Implement ignore all logic + MessageBox.Show(CxAssistConstants.GetIgnoreAllSuccessMessage(vulnerability.Scanner), 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 + + /// + /// Copy selected item details to clipboard (full display text). + /// + private void CopyMenuItem_Click(object sender, RoutedEventArgs e) + { + var vuln = GetSelectedVulnerability(); + if (vuln != null) + Clipboard.SetText(vuln.DisplayText); + } + + /// + /// Copy short message to clipboard (reference "Copy Message": e.g. "High-risk package: validator@13.12"). + /// + private void CopyMessage_Click(object sender, RoutedEventArgs e) + { + var vuln = GetSelectedVulnerability(); + if (vuln != null && !string.IsNullOrEmpty(vuln.PrimaryDisplayText)) + Clipboard.SetText(vuln.PrimaryDisplayText); + } + + /// + /// 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..5ead5ed7 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}}"> @@ -25,31 +27,60 @@ + + + + + + + + + + - + + + - + @@ -698,22 +771,16 @@ - - + + - - - + + + - + @@ -806,7 +873,46 @@ + + + + + + + + + +