diff --git a/.gitignore b/.gitignore index e03afa0..ac7ba56 100644 --- a/.gitignore +++ b/.gitignore @@ -202,8 +202,6 @@ PublishScripts/ !**/[Pp]ackages/build/ # MSBuild Guard scoped trust stores (solution/project level) -**/.msbuildguard/trust.json -**/.msbuildguard/trust.json.audit.jsonl # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -373,3 +371,8 @@ FodyWeavers.xsd /.msbuildguard /plans /MSBuildGuard.VSCode/*.vsix + +.msbuildguard +/.msbuildguard/ +**/.msbuildguard/trust.json +**/.msbuildguard/trust.json.audit.jsonl diff --git a/MSBuildGuard.Core.Tests/Baseline/BaselineOnboardingServiceTests.cs b/MSBuildGuard.Core.Tests/Baseline/BaselineOnboardingServiceTests.cs index 8c4a33f..f171202 100644 --- a/MSBuildGuard.Core.Tests/Baseline/BaselineOnboardingServiceTests.cs +++ b/MSBuildGuard.Core.Tests/Baseline/BaselineOnboardingServiceTests.cs @@ -131,5 +131,37 @@ public async Task GenerateSuggestionsAsync_ShouldSetIsAlreadyTrusted_ForAlreadyT trustStoreService.RemoveDecisionsBySubject(userTrustPath, packageHash, "Clean up", "TestUser"); } } + + /// + /// Verifies that the recommendation reason for a Signer trust suggestion is formatted correctly. + /// + [Test] + [Explicit("Requires internet access and local NuGet package cache.")] + public async Task GenerateSuggestionsAsync_ShouldFormatRecommendationReasonForSigner() + { + var service = new BaselineOnboardingService(); + var report = new ScanReport(); + + report.Findings.Add(new Finding + { + Id = "MBG001", + Fingerprint = "fp-1", + PackageId = "System.Text.Json", + PackageVersion = "10.0.7", + FilePath = "somepath" + }); + + var result = await service.GenerateSuggestionsAsync(report, CancellationToken.None); + + result.ShouldNotBeNull(); + + var signerSuggestion = result.FirstOrDefault(item => item.Scope == TrustSuggestionScope.Signer); + + signerSuggestion.ShouldNotBeNull(); + signerSuggestion.RecommendationReason.ShouldStartWith("Signer:"); + signerSuggestion.RecommendationReason.ShouldContain("System.Text.Json"); + signerSuggestion.RecommendationReason.ShouldContain("System.Text.Json.dll"); + signerSuggestion.RecommendationReason.ShouldContain(signerSuggestion.DisplayName); + } } } diff --git a/MSBuildGuard.Core/Baseline/BaselineOnboardingService.cs b/MSBuildGuard.Core/Baseline/BaselineOnboardingService.cs index 0f08980..97d61ad 100644 --- a/MSBuildGuard.Core/Baseline/BaselineOnboardingService.cs +++ b/MSBuildGuard.Core/Baseline/BaselineOnboardingService.cs @@ -139,13 +139,13 @@ public async Task> GenerateSuggestionsAsync(ScanReport rep processedPackages.Add(packageKey); var assemblyPath = AssemblySignatureService.ResolveAssemblyFilePathFromPackageId(packageId, packageVersion); + var assemblyName = !string.IsNullOrEmpty(assemblyPath) ? Path.GetFileName(assemblyPath) : string.Empty; var signatureInfo = signatureService.ReadSignature(assemblyPath); var reputationInfo = await this.reputationService.GetReputationAsync(packageId, cancellationToken).ConfigureAwait(false); if (signatureInfo != null && signatureInfo.HasEmbeddedSignature && signatureInfo.IsSignatureValid) { - var isMicrosoft = signatureInfo.Signer.IndexOf("Microsoft", StringComparison.OrdinalIgnoreCase) >= 0 || - signatureInfo.Subject.IndexOf("Microsoft", StringComparison.OrdinalIgnoreCase) >= 0; + var isMicrosoft = IsTrustedMicrosoftSigner(signatureInfo); if (isMicrosoft) { @@ -161,10 +161,10 @@ public async Task> GenerateSuggestionsAsync(ScanReport rep IsSelected = true, Scope = TrustSuggestionScope.Signer, Subject = signatureInfo.Thumbprint, - DisplayName = signatureInfo.Signer, - RecommendationReason = "This package is signed by Microsoft Corporation with a valid Authenticode signature.", + DisplayName = $"{packageId} ({signatureInfo.Signer})", // signatureInfo.Signer, + RecommendationReason = $"Trusted signer: {signatureInfo.Signer}", ReputationSourceDescription = "Verified Publisher (Microsoft)" - }; + }; suggestion.Metadata["SignerThumbprint"] = signatureInfo.Thumbprint; suggestion.Metadata["SignerSubject"] = signatureInfo.Subject; @@ -186,11 +186,11 @@ public async Task> GenerateSuggestionsAsync(ScanReport rep { var suggestion = new TrustSuggestion { - IsSelected = true, - Scope = TrustSuggestionScope.Package, - Subject = packageHash, - DisplayName = $"{packageId} v{packageVersion}", - RecommendationReason = $"Verified package on NuGet.org with very high download volume ({reputationInfo.TotalDownloads:N0} downloads).", + IsSelected = true, + Scope = TrustSuggestionScope.Package, + Subject = packageHash, + DisplayName = $"{packageId} v{packageVersion}", + RecommendationReason = $"Verified package on NuGet.org with very high download volume ({reputationInfo.TotalDownloads:N0} downloads).", ReputationSourceDescription = "Verified NuGet.org Publisher" }; @@ -214,10 +214,10 @@ public async Task> GenerateSuggestionsAsync(ScanReport rep var suggestion = new TrustSuggestion { - IsSelected = true, - Scope = TrustSuggestionScope.Signer, - Subject = signatureInfo.Thumbprint, - DisplayName = signatureInfo.Signer, + IsSelected = true, + Scope = TrustSuggestionScope.Signer, + Subject = signatureInfo.Thumbprint, + DisplayName = signatureInfo.Signer, RecommendationReason = $"Signed by a valid certificate signer: '{signatureInfo.Signer}'.", ReputationSourceDescription = "Valid Authenticode Signer" }; @@ -302,5 +302,27 @@ private static bool IsSuggestionAlreadyTrusted( return false; } + + /// + /// Verifies that the signature belongs to a trusted Microsoft signer. + /// + /// The signature info to check. + /// if the signature belongs to Microsoft; otherwise . + private static bool IsTrustedMicrosoftSigner(AssemblySignatureInfo signatureInfo) + { + if (signatureInfo == null || !signatureInfo.IsSignatureValid) + { + return false; + } + + var subject = signatureInfo.Subject; + + return subject.IndexOf("O=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) >= 0 || + subject.IndexOf("O=\"Microsoft Corporation\"", StringComparison.OrdinalIgnoreCase) >= 0 || + subject.IndexOf("OU=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) >= 0 || + subject.IndexOf("OU=\"Microsoft Corporation\"", StringComparison.OrdinalIgnoreCase) >= 0 || + subject.IndexOf("CN=Microsoft Corporation", StringComparison.OrdinalIgnoreCase) >= 0 || + subject.IndexOf("CN=\"Microsoft Corporation\"", StringComparison.OrdinalIgnoreCase) >= 0; + } } } diff --git a/MSBuildGuard.VSCode/package-lock.json b/MSBuildGuard.VSCode/package-lock.json index e1443f7..717cd10 100644 --- a/MSBuildGuard.VSCode/package-lock.json +++ b/MSBuildGuard.VSCode/package-lock.json @@ -1,12 +1,12 @@ { "name": "msbuildguard", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "msbuildguard", - "version": "0.1.0", + "version": "0.1.1", "license": "PolyForm-Noncommercial-1.0.0", "devDependencies": { "@types/node": "16.18.34", diff --git a/MSBuildGuard.VSCode/package.json b/MSBuildGuard.VSCode/package.json index 6fd2ee5..2264d3b 100644 --- a/MSBuildGuard.VSCode/package.json +++ b/MSBuildGuard.VSCode/package.json @@ -3,7 +3,7 @@ "displayName": "MSBuild Guard", "description": "Cross-platform security analysis extension that scans MSBuild project files before execution and prevents malicious code delivery.", "icon": "resources/shield-icon.png", - "version": "0.1.0", + "version": "0.1.1", "preview": true, "publisher": "Hefaistos68", "author": { diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs new file mode 100644 index 0000000..4b351c5 --- /dev/null +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/BuildBlockDialogViewModelTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.IO; +using MSBuildGuard.Core; +using MSBuildGuard.Core.Trust; +using MSBuildGuard.VisualStudio.ToolWindows; +using NUnit.Framework; +using Shouldly; + +namespace MSBuildGuard.VisualStudio.ToolWindows.Tests +{ + /// + /// Unit tests for the class. + /// + [TestFixture] + public sealed class BuildBlockDialogViewModelTests + { + private string tempDir = string.Empty; + + /// + /// Sets up the test environment. + /// + [SetUp] + public void SetUp() + { + this.tempDir = Path.Combine(Path.GetTempPath(), "MSBuildGuardTests", Guid.NewGuid().ToString("N")); + + Directory.CreateDirectory(this.tempDir); + } + + /// + /// Tears down the test environment. + /// + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.tempDir)) + { + try + { + Directory.Delete(this.tempDir, true); + } + catch + { + // Ignore clean up errors. + } + } + } + + /// + /// Verifies that risk score calculation correctly ignores trusted findings. + /// + [Test] + public void Constructor_WithTrustedFindings_CalculatesCorrectRiskScore() + { + var report = new ScanReport(); + + report.Target.TargetPath = Path.Combine(this.tempDir, "TestSolution.sln"); + report.Target.TargetKind = TargetKind.Solution; + + var finding = new Finding + { + Id = "MBG001", + Title = "Test Finding", + Severity = FindingSeverity.Medium, + FilePath = Path.Combine(this.tempDir, "TestProj.csproj"), + Fingerprint = "fingerprint-1" + }; + + report.Findings.Add(finding); + + var fileRecord = new MsBuildFileRecord + { + Path = finding.FilePath, + NormalizedSha256 = "sha256-hash-value" + }; + + report.FilesScanned.Add(fileRecord); + + var userTrustPath = new TrustStoreService().GetDefaultUserTrustPath(); + var model = new BuildBlockDialogViewModel(report, this.tempDir); + + // Initially, the finding is not trusted. + model.RiskScore.ShouldBe(20); + + // Now we write a trust entry for it. + var trustStoreService = new TrustStoreService(); + var userTrustStore = trustStoreService.Load(userTrustPath); + var originalDecisions = new List(userTrustStore.Decisions); + + try + { + trustStoreService.AddDecision(userTrustPath, new TrustDecisionEntry + { + DecisionId = Guid.NewGuid().ToString("N"), + Scope = "Finding", + SubjectHash = "fingerprint-1", + Decision = "Trust", + Reason = "Test trust", + UserSid = "TestSid", + CreatedAtUtc = DateTimeOffset.UtcNow + }); + + var model2 = new BuildBlockDialogViewModel(report, this.tempDir); + + model2.RiskScore.ShouldBe(0); + model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString()); + } + finally + { + // Restore the original user trust store to not pollute the host. + userTrustStore.Decisions.Clear(); + + foreach (var d in originalDecisions) + { + userTrustStore.Decisions.Add(d); + } + + trustStoreService.Save(userTrustPath, userTrustStore); + } + } + } +} diff --git a/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj b/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj index 5d329bb..742f051 100644 --- a/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj +++ b/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj @@ -37,7 +37,7 @@ MSBuildGuard.VisualStudio MSBuildGuard MSBuildGuard - 0.3.0 + 0.3.1 False Hefaistos68 Hefaistos68.dev diff --git a/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs b/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs index 9006567..f2a832b 100644 --- a/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs +++ b/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs @@ -46,6 +46,11 @@ public sealed class MSBuildGuardPackage : AsyncPackage /// private Core.ScanReport? latestScanReport; + /// + /// Stores the scan report for which the effective risk was calculated. + /// + private Core.ScanReport? calculatedRiskReport; + private bool isLatestReportGreen; private int latestReportEffectiveRiskScore; @@ -203,6 +208,9 @@ internal async Task OnSolutionUnloadedAsync() { await this.UiFeedbackService.WriteLineAsync("Solution unloaded. Clearing scan and review state.", CancellationToken.None); this.latestScanReport = null; + this.calculatedRiskReport = null; + this.latestReportEffectiveRiskScore = 0; + this.isLatestReportGreen = false; this.reviewSelectionService.SolutionReviewTargetPath = null; await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); @@ -377,7 +385,7 @@ private async Task RefreshStatusBarShieldAsync() int? effectiveRiskScore = null; - if (this.latestScanReport != null) + if (this.latestScanReport != null && this.latestScanReport == this.calculatedRiskReport) { effectiveRiskScore = this.latestReportEffectiveRiskScore; } @@ -857,6 +865,7 @@ internal async Task RecalculateEffectiveRiskAsync() { this.isLatestReportGreen = false; this.latestReportEffectiveRiskScore = 0; + this.calculatedRiskReport = null; return; } @@ -870,12 +879,22 @@ internal async Task RecalculateEffectiveRiskAsync() var buildBlockViewModel = new ToolWindows.BuildBlockDialogViewModel(report, solutionPath); - this.isLatestReportGreen = string.Equals(buildBlockViewModel.RecommendedAction, Core.RecommendedAction.Allow.ToString(), StringComparison.OrdinalIgnoreCase); - this.latestReportEffectiveRiskScore = buildBlockViewModel.RiskScore; + var isGreen = string.Equals(buildBlockViewModel.RecommendedAction, Core.RecommendedAction.Allow.ToString(), StringComparison.OrdinalIgnoreCase); + + var riskScore = buildBlockViewModel.RiskScore; // Switch back to the UI thread to update controls and trigger VS menu updates await JoinableTaskFactory.SwitchToMainThreadAsync(DisposalToken); + if (report != this.latestScanReport) + { + return; + } + + this.isLatestReportGreen = isGreen; + this.latestReportEffectiveRiskScore = riskScore; + this.calculatedRiskReport = report; + await this.RefreshStatusBarShieldAsync().ConfigureAwait(false); if (await this.GetServiceAsync(typeof(SVsUIShell)) is IVsUIShell uiShell) diff --git a/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs b/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs index e7bf9bd..906aef3 100644 --- a/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs +++ b/MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs @@ -157,6 +157,12 @@ public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string } } + if (string.IsNullOrWhiteSpace(finding.PackageId) && TryInferPackageFromPath(finding.FilePath, out var inferredId, out var inferredVersion)) + { + finding.PackageId = inferredId; + finding.PackageVersion = inferredVersion; + } + var fileRecord = report.FilesScanned.FirstOrDefault(item => string.Equals(item.Path, finding.FilePath, StringComparison.OrdinalIgnoreCase)); var projectTrustStore = GetProjectTrustStore(solutionPath, finding.IntroducedViaProject, trustStoreService, projectTrustStoreCache); var isTrusted = !string.IsNullOrWhiteSpace(finding.Fingerprint) && @@ -344,6 +350,52 @@ private static bool IsAssemblyApproved( return false; } + private static bool TryInferPackageFromPath(string filePath, out string packageId, out string packageVersion) + { + packageId = string.Empty; + packageVersion = string.Empty; + + if (string.IsNullOrWhiteSpace(filePath)) + { + return false; + } + + var directory = Path.GetDirectoryName(filePath); + + var candidate = directory; + + for (var depth = 0; depth < 6 && !string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate); depth++) + { + if (Directory.Exists(Path.Combine(candidate, "lib")) || + Directory.Exists(Path.Combine(candidate, "tools")) || + Directory.Exists(Path.Combine(candidate, "runtimes"))) + { + var version = Path.GetFileName(candidate); + + var parentDir = Path.GetDirectoryName(candidate); + + if (!string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(parentDir)) + { + var id = Path.GetFileName(parentDir); + + if (!string.IsNullOrWhiteSpace(id)) + { + packageId = id; + packageVersion = version; + + return true; + } + } + + break; + } + + candidate = Path.GetDirectoryName(candidate); + } + + return false; + } + private static int GetSeverityRisk(FindingSeverity severity) { switch (severity) diff --git a/MSBuildGuard.VisualStudio/source.extension.vsixmanifest b/MSBuildGuard.VisualStudio/source.extension.vsixmanifest index b30eb52..4e673f6 100644 --- a/MSBuildGuard.VisualStudio/source.extension.vsixmanifest +++ b/MSBuildGuard.VisualStudio/source.extension.vsixmanifest @@ -1,6 +1,6 @@ - + MSBuild Guard for Visual Studio Real-time MSBuild security review, trust workflows, build blocking, and dynamic policy enforcement for Visual Studio. https://github.com/Hefaistos68/MSBuildGuard diff --git a/documentation/images/key-management-onboarding.jpg b/documentation/images/key-management-onboarding.jpg new file mode 100644 index 0000000..51375f9 Binary files /dev/null and b/documentation/images/key-management-onboarding.jpg differ