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