From a068e766f71cfdf8c8ba8a7bd97d65eda4f4e2c4 Mon Sep 17 00:00:00 2001 From: Andreas Saurwein Date: Tue, 16 Jun 2026 12:38:29 +0100 Subject: [PATCH 1/5] Security hardening: trust management, signatures, onboarding - Add key management mode selection (DPAPI vs. certificates) with onboarding dialog - Enforce asymmetric signatures for trust/policy files; require `.signature` sidecar - Implement repository pinning for signature enforcement - Add trust purging on settings downgrade and UI commands for trust removal - Support root CA pinning via environment variable - Refactor signature storage for cross-platform compatibility - Update settings, menus, and documentation for new workflows - Add/expand tests for trust management and onboarding - Bump extension/package versions --- MSBuildGuard.Core.Tests/CoreSettingsTests.cs | 32 + .../Policy/PolicyServiceTests.cs | 95 ++- .../Trust/TrustStoreServiceTests.cs | 242 ++++++- MSBuildGuard.Core/CoreSettings.cs | 20 + MSBuildGuard.Core/Policy/PolicyService.cs | 52 +- MSBuildGuard.Core/Trust/TrustStoreService.cs | 632 +++++++++++++++++- MSBuildGuard.VSCode/package-lock.json | 4 +- MSBuildGuard.VSCode/package.json | 33 +- MSBuildGuard.VSCode/src/extension.ts | 385 +++++++++++ .../src/services/workerClient.ts | 11 +- .../src/views/onboardingView.ts | 7 + .../src/views/securityReviewView.ts | 29 + .../KeyManagementOnboardingViewModelTests.cs | 38 ++ .../ManageAssemblyTrustsHelperTests.cs | 8 +- .../ManagePackageTrustsHelperTests.cs | 4 +- .../ManageSignerTrustsHelperTests.cs | 10 +- .../MSBuildGuard.VisualStudio.csproj | 2 +- .../MSBuildGuardPackage.cs | 470 +++++++++++++ MSBuildGuard.VisualStudio/Menus.vsct | 32 +- .../Options/MSBuildGuardOptionsPage.cs | 54 +- .../Options/MSBuildGuardOptionsSnapshot.cs | 10 + .../Options/SettingsNames.cs | 14 + .../Options/UnifiedSettingsOptionsProvider.cs | 10 + MSBuildGuard.VisualStudio/PackageIds.cs | 10 + .../KeyManagementOnboardingDialog.xaml | 113 ++++ .../KeyManagementOnboardingDialog.xaml.cs | 54 ++ .../KeyManagementOnboardingViewModel.cs | 15 + .../SolutionSecurityReviewControl.xaml | 26 +- .../SolutionSecurityReviewControl.xaml.cs | 29 + .../SolutionSecurityReviewViewModel.cs | 99 +++ .../msbuildguard.registration.json | 50 +- .../source.extension.vsixmanifest | 2 +- MSBuildGuard.Worker/Program.cs | 14 + README.md | 3 + documentation/ADVANCED-TRUST-MANAGEMENT.md | 107 +++ 35 files changed, 2635 insertions(+), 81 deletions(-) create mode 100644 MSBuildGuard.Core.Tests/CoreSettingsTests.cs create mode 100644 MSBuildGuard.Core/CoreSettings.cs create mode 100644 MSBuildGuard.VisualStudio.Tests/ToolWindows/KeyManagementOnboardingViewModelTests.cs create mode 100644 MSBuildGuard.VisualStudio/ToolWindows/KeyManagementOnboardingDialog.xaml create mode 100644 MSBuildGuard.VisualStudio/ToolWindows/KeyManagementOnboardingDialog.xaml.cs create mode 100644 MSBuildGuard.VisualStudio/ToolWindows/KeyManagementOnboardingViewModel.cs create mode 100644 documentation/ADVANCED-TRUST-MANAGEMENT.md diff --git a/MSBuildGuard.Core.Tests/CoreSettingsTests.cs b/MSBuildGuard.Core.Tests/CoreSettingsTests.cs new file mode 100644 index 0000000..0251aef --- /dev/null +++ b/MSBuildGuard.Core.Tests/CoreSettingsTests.cs @@ -0,0 +1,32 @@ +using System; +using NUnit.Framework; +using Shouldly; + +namespace MSBuildGuard.Core.Tests +{ + /// + /// Tests for . + /// + [TestFixture] + public sealed class CoreSettingsTests + { + /// + /// Verifies that CoreSettings properties can be set and get successfully. + /// + [Test] + public void CoreSettings_ShouldSetAndGetProperties() + { + CoreSettings.EnforceAsymmetricSignatures = true; + CoreSettings.AllowSharingTrustsInRepositories = true; + + CoreSettings.EnforceAsymmetricSignatures.ShouldBeTrue(); + CoreSettings.AllowSharingTrustsInRepositories.ShouldBeTrue(); + + CoreSettings.EnforceAsymmetricSignatures = false; + CoreSettings.AllowSharingTrustsInRepositories = false; + + CoreSettings.EnforceAsymmetricSignatures.ShouldBeFalse(); + CoreSettings.AllowSharingTrustsInRepositories.ShouldBeFalse(); + } + } +} diff --git a/MSBuildGuard.Core.Tests/Policy/PolicyServiceTests.cs b/MSBuildGuard.Core.Tests/Policy/PolicyServiceTests.cs index 3d6adca..bceb822 100644 --- a/MSBuildGuard.Core.Tests/Policy/PolicyServiceTests.cs +++ b/MSBuildGuard.Core.Tests/Policy/PolicyServiceTests.cs @@ -112,11 +112,6 @@ public void ResolveAction_ShouldUseIncompleteAnalysisAction_WhenStrictModeIsDisa [Test] public void SignAndValidate_ShouldSucceed_WhenPolicyHasNotChanged() { - if (!OperatingSystem.IsWindows()) - { - Assert.Ignore("Policy signature stream tests require Windows NTFS alternate stream support."); - } - var service = new PolicyService(); var policyPath = Path.Combine(Path.GetTempPath(), $"policy-sign-{Guid.NewGuid():N}.json"); using var certificate = CreateSelfSignedCertificate(); @@ -237,11 +232,6 @@ public void TryValidateSignature_ShouldFail_WhenJsonEnvelopeIsInvalidWithoutExte [Test] public void Validate_ShouldFail_WhenPolicyContentWasModifiedAfterSigning() { - if (!OperatingSystem.IsWindows()) - { - Assert.Ignore("Policy signature stream tests require Windows NTFS alternate stream support."); - } - var service = new PolicyService(); var policyPath = Path.Combine(Path.GetTempPath(), $"policy-tamper-{Guid.NewGuid():N}.json"); using var certificate = CreateSelfSignedCertificate(); @@ -305,7 +295,92 @@ public void Load_ShouldSucceed_WhenRawJsonHasLowercaseEnumKeys() } } + /// + /// Verifies that policy signature validation fails when Root CA chain pinning is enabled and the signing certificate is not issued by the pinned Root CA. + /// + [Test] + public void Validate_ShouldFail_WhenRootCaPinningIsEnforcedAndCertificateIsNotIssuedByPinnedRootCa() + { + var service = new PolicyService(); + var policyPath = Path.Combine(Path.GetTempPath(), $"policy-ca-pin-fail-{Guid.NewGuid():N}.json"); + using var certificate = CreateSelfSignedCertificate(); + + AddCertificate(StoreName.My, StoreLocation.CurrentUser, certificate); + AddCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate); + Environment.SetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE", "true"); + Environment.SetEnvironmentVariable("MSBUILDGUARD_ROOT_CA_THUMBPRINT", "0000000000000000000000000000000000000000"); + + try + { + service.Save(policyPath, service.CreateDefault()); + service.Sign(policyPath, certificate.Thumbprint); + + service.TryValidateSignature(policyPath, out var message).ShouldBeFalse(); + message.ShouldContain("The verification certificate is not issued by the trusted Root CA"); + } + finally + { + Environment.SetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE", null); + Environment.SetEnvironmentVariable("MSBUILDGUARD_ROOT_CA_THUMBPRINT", null); + RemoveCertificate(StoreName.My, StoreLocation.CurrentUser, certificate.Thumbprint); + RemoveCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate.Thumbprint); + + if (File.Exists(policyPath)) + { + File.Delete(policyPath); + } + + var signaturePath = policyPath + ".signature"; + + if (File.Exists(signaturePath)) + { + File.Delete(signaturePath); + } + } + } + + /// + /// Verifies that policy signature validation succeeds when Root CA chain pinning is enabled and the signing certificate matches the pinned Root CA. + /// + [Test] + public void Validate_ShouldSucceed_WhenRootCaPinningIsEnforcedAndCertificateMatchesPinnedRootCa() + { + var service = new PolicyService(); + var policyPath = Path.Combine(Path.GetTempPath(), $"policy-ca-pin-success-{Guid.NewGuid():N}.json"); + using var certificate = CreateSelfSignedCertificate(); + + AddCertificate(StoreName.My, StoreLocation.CurrentUser, certificate); + AddCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate); + Environment.SetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE", "true"); + Environment.SetEnvironmentVariable("MSBUILDGUARD_ROOT_CA_THUMBPRINT", certificate.Thumbprint); + + try + { + service.Save(policyPath, service.CreateDefault()); + service.Sign(policyPath, certificate.Thumbprint); + service.TryValidateSignature(policyPath, out var message).ShouldBeTrue(message); + } + finally + { + Environment.SetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE", null); + Environment.SetEnvironmentVariable("MSBUILDGUARD_ROOT_CA_THUMBPRINT", null); + RemoveCertificate(StoreName.My, StoreLocation.CurrentUser, certificate.Thumbprint); + RemoveCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate.Thumbprint); + + if (File.Exists(policyPath)) + { + File.Delete(policyPath); + } + + var signaturePath = policyPath + ".signature"; + + if (File.Exists(signaturePath)) + { + File.Delete(signaturePath); + } + } + } private static X509Certificate2 CreateSelfSignedCertificate() { diff --git a/MSBuildGuard.Core.Tests/Trust/TrustStoreServiceTests.cs b/MSBuildGuard.Core.Tests/Trust/TrustStoreServiceTests.cs index b375306..77a88a6 100644 --- a/MSBuildGuard.Core.Tests/Trust/TrustStoreServiceTests.cs +++ b/MSBuildGuard.Core.Tests/Trust/TrustStoreServiceTests.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using MSBuildGuard.Core.Trust; using NUnit.Framework; using Shouldly; @@ -393,19 +395,56 @@ public void LoadMergedTrustStore_ShouldAggregateDecisionsAcrossAllScopes() } /// - /// Verifies loading a raw JSON trust store with camelCase properties succeeds. + /// Verifies loading a raw JSON trust store fails. /// [Test] - public void Load_ShouldSucceed_WhenRawJsonHasCamelCaseProperties() + public void Load_ShouldFail_WhenRawJsonIsUnsigned() { var service = new TrustStoreService(); + var path = Path.Combine(Path.GetTempPath(), $"trust-raw-{Guid.NewGuid():N}.json"); + var rawJson = "{\r\n \"version\": 1,\r\n \"decisions\": [\r\n {\r\n \"decisionId\": \"5cd109faa53d41158955c652300e9ea9\",\r\n \"scope\": \"Signer\",\r\n \"subjectHash\": \"EC240824852A50662166EA955B4BAD3E180440AD\",\r\n \"decision\": \"Trust\",\r\n \"reason\": \"Trusted\",\r\n \"userSid\": \"andreas\"\r\n }\r\n ]\r\n}"; try { File.WriteAllText(path, rawJson); + Should.Throw(() => service.Load(path)); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + + /// + /// Verifies loading a signed JSON trust store with camelCase properties inside envelope succeeds. + /// + [Test] + public void Load_ShouldSucceed_WhenSignedEnvelopeHasCamelCaseProperties() + { + var service = new TrustStoreService(); + + var path = Path.Combine(Path.GetTempPath(), $"trust-raw-{Guid.NewGuid():N}.json"); + + var rawJson = "{\r\n \"version\": 1,\r\n \"decisions\": [\r\n {\r\n \"decisionId\": \"5cd109faa53d41158955c652300e9ea9\",\r\n \"scope\": \"Signer\",\r\n \"subjectHash\": \"EC240824852A50662166EA955B4BAD3E180440AD\",\r\n \"decision\": \"Trust\",\r\n \"reason\": \"Trusted\",\r\n \"userSid\": \"andreas\"\r\n }\r\n ]\r\n}"; + + var originalAllowSharing = CoreSettings.AllowSharingTrustsInRepositories; + + try + { + CoreSettings.AllowSharingTrustsInRepositories = true; + + var signatureService = new MSBuildGuard.Core.Baseline.JsonSignatureService(); + + var signedPayload = signatureService.CreateSignedEnvelopeJson(rawJson, "MSBuildGuard.TrustStore.v1"); + + File.WriteAllText(path, signedPayload); + var store = service.Load(path); store.ShouldNotBeNull(); @@ -418,6 +457,8 @@ public void Load_ShouldSucceed_WhenRawJsonHasCamelCaseProperties() } finally { + CoreSettings.AllowSharingTrustsInRepositories = originalAllowSharing; + if (File.Exists(path)) { File.Delete(path); @@ -541,5 +582,202 @@ public void IsFindingApprovedByPackage_ShouldApproveOrRejectMatchingPackages() service.IsFindingApprovedByPackage(otherStore, packageId, packageVersion).ShouldBeFalse(); } + + /// + /// Verifies that Load throws an InvalidDataException when EnforceAsymmetricSignatures is true and the signature stream is missing. + /// + [Test] + public void Load_ShouldThrowInvalidDataException_WhenEnforceAsymmetricSignaturesIsTrueAndSignatureIsMissing() + { + var service = new TrustStoreService(); + var rootPath = Path.Combine(Path.GetTempPath(), $"trust-test-{Guid.NewGuid():N}"); + var slnDir = Path.Combine(rootPath, "repo", ".msbuildguard"); + var path = Path.Combine(slnDir, "trust.json"); + + var originalEnforce = CoreSettings.EnforceAsymmetricSignatures; + var originalAllowSharing = CoreSettings.AllowSharingTrustsInRepositories; + + try + { + CoreSettings.EnforceAsymmetricSignatures = true; + CoreSettings.AllowSharingTrustsInRepositories = true; + + service.Save(path, new TrustStoreDocument()); + + Should.Throw(() => service.Load(path)) + .Message.ShouldContain("Asymmetric signature is required but missing"); + } + finally + { + CoreSettings.EnforceAsymmetricSignatures = originalEnforce; + CoreSettings.AllowSharingTrustsInRepositories = originalAllowSharing; + + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, true); + } + } + } + + /// + /// Verifies that loading a trust store file which has an asymmetric signature triggers repository pinning, + /// and subsequent loads of an unsigned trust store in the same directory fail even if global enforcement is false. + /// + [Test] + public void Load_ShouldEnforceAsymmetricSignature_WhenRepositoryIsPinned() + { + var service = new TrustStoreService(); + var rootPath = Path.Combine(Path.GetTempPath(), $"trust-pin-{Guid.NewGuid():N}"); + var slnDir = Path.Combine(rootPath, "repo", ".msbuildguard"); + var path = Path.Combine(slnDir, "trust.json"); + + using var certificate = CreateSelfSignedCertificate(); + + AddCertificate(StoreName.My, StoreLocation.CurrentUser, certificate); + AddCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate); + Environment.SetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE", "true"); + + var originalAllowSharing = CoreSettings.AllowSharingTrustsInRepositories; + var originalEnforce = CoreSettings.EnforceAsymmetricSignatures; + + try + { + CoreSettings.AllowSharingTrustsInRepositories = true; + CoreSettings.EnforceAsymmetricSignatures = false; + + service.Save(path, new TrustStoreDocument()); + service.Sign(path, certificate.Thumbprint); + + // Loading the signed file should succeed and trigger pinning + var loaded = service.Load(path); + + loaded.ShouldNotBeNull(); + + // Delete the signature stream to simulate an unsigned file update + var sigPath = path + ".signature"; + + if (File.Exists(sigPath)) + { + File.Delete(sigPath); + } + + // Trying to load the now-unsigned file in the pinned repository should throw + Should.Throw(() => service.Load(path)) + .Message.ShouldContain("Asymmetric signature is required for this pinned repository"); + } + finally + { + CoreSettings.AllowSharingTrustsInRepositories = originalAllowSharing; + CoreSettings.EnforceAsymmetricSignatures = originalEnforce; + Environment.SetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE", null); + RemoveCertificate(StoreName.My, StoreLocation.CurrentUser, certificate.Thumbprint); + RemoveCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, certificate.Thumbprint); + + if (Directory.Exists(rootPath)) + { + Directory.Delete(rootPath, true); + } + } + } + + /// + /// Verifies that loading a trust store fails when the audit log file has been modified (chain link broken). + /// + [Test] + public void Load_ShouldThrowInvalidDataException_WhenAuditTrailIsTampered() + { + var service = new TrustStoreService(); + var path = Path.Combine(Path.GetTempPath(), $"trust-tamper-{Guid.NewGuid():N}.json"); + + try + { + service.AddDecision(path, new TrustDecisionEntry + { + CreatedAtUtc = DateTimeOffset.UtcNow, + Decision = "TrustUntilChanged", + DecisionId = Guid.NewGuid().ToString("N"), + Reason = "approval", + Scope = "Finding", + SubjectHash = "fp-1", + UserSid = "tester" + }); + + service.AddDecision(path, new TrustDecisionEntry + { + CreatedAtUtc = DateTimeOffset.UtcNow, + Decision = "TrustUntilChanged", + DecisionId = Guid.NewGuid().ToString("N"), + Reason = "approval", + Scope = "Finding", + SubjectHash = "fp-2", + UserSid = "tester" + }); + + var auditPath = service.GetAuditPathForStore(path); + var lines = File.ReadAllLines(auditPath); + + // Modify a line in the middle to break the hash chain + if (lines.Length >= 2) + { + lines[0] = lines[0].Replace("fp-1", "fp-1-tampered"); + File.WriteAllLines(auditPath, lines); + } + + Should.Throw(() => service.Load(path)); + } + finally + { + if (File.Exists(path)) + { + File.Delete(path); + } + + var auditPath = service.GetAuditPathForStore(path); + + if (File.Exists(auditPath)) + { + File.Delete(auditPath); + } + } + } + + private static X509Certificate2 CreateSelfSignedCertificate() + { + using var rsa = RSA.Create(2048); + var request = new CertificateRequest( + $"CN=MSBuildGuard-TrustTests-{Guid.NewGuid():N}", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + + var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(7)); + var pfx = certificate.Export(X509ContentType.Pfx); + + return X509CertificateLoader.LoadPkcs12(pfx, string.Empty, X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + } + + private static void AddCertificate(StoreName storeName, StoreLocation storeLocation, X509Certificate2 certificate) + { + using var store = new X509Store(storeName, storeLocation); + + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + } + + private static void RemoveCertificate(StoreName storeName, StoreLocation storeLocation, string thumbprint) + { + using var store = new X509Store(storeName, storeLocation); + + store.Open(OpenFlags.ReadWrite); + var certificates = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + + foreach (var certificate in certificates) + { + store.Remove(certificate); + } + } } } diff --git a/MSBuildGuard.Core/CoreSettings.cs b/MSBuildGuard.Core/CoreSettings.cs new file mode 100644 index 0000000..1f909b1 --- /dev/null +++ b/MSBuildGuard.Core/CoreSettings.cs @@ -0,0 +1,20 @@ +using System; + +namespace MSBuildGuard.Core +{ + /// + /// Provides static configuration settings for MSBuildGuard core services. + /// + public static class CoreSettings + { + /// + /// Gets or sets a value indicating whether asymmetric certificate-based signature verification is strictly enforced. + /// + public static bool EnforceAsymmetricSignatures { get; set; } + + /// + /// Gets or sets a value indicating whether sharing trusts in repositories is allowed. + /// + public static bool AllowSharingTrustsInRepositories { get; set; } + } +} diff --git a/MSBuildGuard.Core/Policy/PolicyService.cs b/MSBuildGuard.Core/Policy/PolicyService.cs index a93d7c9..86a80f6 100644 --- a/MSBuildGuard.Core/Policy/PolicyService.cs +++ b/MSBuildGuard.Core/Policy/PolicyService.cs @@ -323,6 +323,11 @@ public void ValidateSignedPolicy(string policyPath) if (!TryReadSignatureRecord(policyPath, out var signatureRecord)) { + if (CoreSettings.EnforceAsymmetricSignatures) + { + throw new InvalidDataException("Policy asymmetric signature is required but missing. Run 'msbuildguard policy sign ' to sign."); + } + return; } @@ -410,7 +415,7 @@ public string GetSignatureStreamPath(string policyPath) throw new ArgumentNullException(nameof(policyPath)); } - return string.Concat(policyPath, ":", PolicySignatureStreamName); + return string.Concat(policyPath, ".signature"); } /// @@ -666,22 +671,47 @@ private X509Certificate2 LoadTrustedVerificationCertificateOrThrow(string thumbp var normalized = NormalizeThumbprint(thumbprint); var certificate = TryFindCertificate(StoreName.TrustedPeople, StoreLocation.LocalMachine, normalized); - if (certificate != null) + if (certificate == null && AllowCurrentUserTrustedStore()) + { + certificate = TryFindCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, normalized); + } + + if (certificate == null) { - return certificate; + throw new InvalidDataException($"Trusted verification certificate '{normalized}' was not found in LocalMachine/TrustedPeople. Import signer public certificate there. For test/dev only, set {AllowCurrentUserTrustedStoreVariable}=true to allow CurrentUser/TrustedPeople fallback."); } - if (AllowCurrentUserTrustedStore()) + var pinnedCa = Environment.GetEnvironmentVariable("MSBUILDGUARD_ROOT_CA_THUMBPRINT"); + + if (!string.IsNullOrWhiteSpace(pinnedCa)) { - certificate = TryFindCertificate(StoreName.TrustedPeople, StoreLocation.CurrentUser, normalized); + var normalizedPinnedCa = pinnedCa!.Replace(" ", string.Empty).ToUpperInvariant(); + var chain = new X509Chain(); - if (certificate != null) + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.Build(certificate); + + var isChainValid = false; + + foreach (var element in chain.ChainElements) + { + var elementThumb = element.Certificate.Thumbprint.Replace(" ", string.Empty).ToUpperInvariant(); + + if (string.Equals(elementThumb, normalizedPinnedCa, StringComparison.OrdinalIgnoreCase)) + { + isChainValid = true; + + break; + } + } + + if (!isChainValid) { - return certificate; + throw new InvalidDataException($"The verification certificate is not issued by the trusted Root CA '{normalizedPinnedCa}'."); } } - throw new InvalidDataException($"Trusted verification certificate '{normalized}' was not found in LocalMachine/TrustedPeople. Import signer public certificate there. For test/dev only, set {AllowCurrentUserTrustedStoreVariable}=true to allow CurrentUser/TrustedPeople fallback."); + return certificate; } /// @@ -790,15 +820,15 @@ private PolicySignatureRecord ReadSignatureRecord(string policyPath) } catch (FileNotFoundException ex) { - throw new InvalidDataException("Policy signature stream is missing. The policy may have been copied to a file system that strips alternate data streams (for example non-NTFS) or edited without re-signing. Run 'msbuildguard policy sign ' after legitimate edits.", ex); + throw new InvalidDataException("Policy signature file is missing. The policy may have been copied or edited without re-signing. Run 'msbuildguard policy sign ' after legitimate edits.", ex); } catch (DirectoryNotFoundException ex) { - throw new InvalidDataException("Policy signature stream is missing. The policy may have been copied to a file system that strips alternate data streams (for example non-NTFS) or edited without re-signing. Run 'msbuildguard policy sign ' after legitimate edits.", ex); + throw new InvalidDataException("Policy signature file is missing. The policy may have been copied or edited without re-signing. Run 'msbuildguard policy sign ' after legitimate edits.", ex); } catch (NotSupportedException ex) { - throw new InvalidDataException("Policy signature stream is not supported on this file system. Use NTFS policy paths.", ex); + throw new InvalidDataException("Policy signature is not supported.", ex); } var signatureRecord = JsonSerializer.Deserialize(signaturePayload, SignatureSerializerOptions); diff --git a/MSBuildGuard.Core/Trust/TrustStoreService.cs b/MSBuildGuard.Core/Trust/TrustStoreService.cs index dca5aff..203f4ee 100644 --- a/MSBuildGuard.Core/Trust/TrustStoreService.cs +++ b/MSBuildGuard.Core/Trust/TrustStoreService.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -15,8 +16,14 @@ namespace MSBuildGuard.Core.Trust /// public sealed class TrustStoreService { + /// + /// Fallback symmetric signing key used when repository trust sharing is enabled. + /// private const string TrustStoreSigningKey = "MSBuildGuard.TrustStore.v1"; + /// + /// Serializer options for trust store documents. + /// private static readonly JsonSerializerOptions SerializerOptions = new JsonSerializerOptions { WriteIndented = true, @@ -24,11 +31,17 @@ public sealed class TrustStoreService Converters = { new JsonStringEnumConverter() } }; + /// + /// Serializer options for line-delimited audit events. + /// private static readonly JsonSerializerOptions AuditSerializerOptions = new JsonSerializerOptions { WriteIndented = false }; + /// + /// In-memory cache of calculated package directory hashes by absolute directory path. + /// private readonly Dictionary packageDirectoryHashCache = new Dictionary(StringComparer.OrdinalIgnoreCase); /// @@ -50,44 +63,54 @@ public TrustStoreDocument Load(string path) var payload = File.ReadAllText(path); var signatureService = new JsonSignatureService(); - var isEnvelopeFormat = false; try { using var doc = JsonDocument.Parse(payload); + isEnvelopeFormat = doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("SignatureV1", out _); } catch (JsonException) { } + if (!isEnvelopeFormat) + { + throw new InvalidDataException("Trust store must be signed. Unsigned trust stores are not allowed."); + } + + var signingKey = GetSigningKeyForPath(path); string? trustPayload; - var isEnvelopeSigned = signatureService.TryVerifyAndExtract(payload, TrustStoreSigningKey, out trustPayload) && !string.IsNullOrWhiteSpace(trustPayload); + var isEnvelopeSigned = signatureService.TryVerifyAndExtract(payload, signingKey, out trustPayload) && !string.IsNullOrWhiteSpace(trustPayload); - if (isEnvelopeFormat) + if (!isEnvelopeSigned) { - if (!isEnvelopeSigned) - { - throw new InvalidDataException("Trust store signature validation failed. Do not modify the contents of this file manually."); - } + throw new InvalidDataException("Trust store signature validation failed. Do not modify the contents of this file manually."); + } + + // Verify optional Asymmetric signature + var hasAsymmetricSignature = TryReadSignatureRecord(path, out _); + + if (hasAsymmetricSignature) + { + ValidateSignedTrust(path); + PinRepositoryAsAsymmetricRequired(path); } else { - try - { - var directTrust = JsonSerializer.Deserialize(payload, SerializerOptions); + var solutionDir = Path.GetDirectoryName(path); + var isSolutionOrProjectScope = !string.IsNullOrWhiteSpace(solutionDir) && (solutionDir.Contains(".msbuildguard") || path.Contains(".msbuildguard")); - if (directTrust != null) - { - return directTrust; - } - } - catch + if (CoreSettings.EnforceAsymmetricSignatures && isSolutionOrProjectScope) { + throw new InvalidDataException("Asymmetric signature is required but missing."); } - throw new InvalidDataException("Trust store signature validation failed. Do not modify the contents of this file manually."); + if (IsRepositoryPinnedAsAsymmetricRequired(path) && isSolutionOrProjectScope) + { + throw new InvalidDataException("Asymmetric signature is required for this pinned repository but is missing."); + } } var trust = JsonSerializer.Deserialize(trustPayload!, SerializerOptions); @@ -97,6 +120,21 @@ public TrustStoreDocument Load(string path) throw new InvalidDataException("Unable to deserialize decrypted trust store content."); } + // Verify audit trail integrity if the audit file exists + var auditPath = GetAuditPathForStore(path); + + if (File.Exists(auditPath)) + { + try + { + ReadAudit(auditPath); + } + catch (Exception ex) + { + throw new InvalidDataException($"Audit trail integrity validation failed for '{path}': {ex.Message}", ex); + } + } + return trust; } @@ -125,7 +163,8 @@ public void Save(string path, TrustStoreDocument document) } var trustPayload = JsonSerializer.Serialize(document, SerializerOptions); - var payload = new JsonSignatureService().CreateSignedEnvelopeJson(trustPayload, TrustStoreSigningKey); + var signingKey = GetSigningKeyForPath(path); + var payload = new JsonSignatureService().CreateSignedEnvelopeJson(trustPayload, signingKey); WriteAllTextAtomic(path, payload); } @@ -773,6 +812,7 @@ public bool IsFindingApproved(TrustStoreDocument store, string fingerprint, stri /// /// Trust store document. /// Repository remote. + /// Repository branch. /// Commit SHA. /// Optional policy profile. /// when a matching active repository or baseline trust decision exists; otherwise . @@ -1053,10 +1093,11 @@ private static void AppendDecisions(TrustStoreDocument destination, TrustStoreDo } /// - /// Appends a trust audit event to the audit log associated with the specified trust store, ensuring that the integrity of the audit chain is maintained through hash linking of events. + /// Appends a trust audit event to the audit log associated with the specified trust store, + /// preserving hash-chain continuity with the previous event. /// - /// - /// + /// Trust store path used to resolve the audit log location. + /// Audit event to append. private void AppendAuditEvent(string trustStorePath, TrustAuditEvent auditEvent) { var auditPath = GetAuditPathForStore(trustStorePath); @@ -1092,13 +1133,13 @@ private void AppendAuditEvent(string trustStorePath, TrustAuditEvent auditEvent) } /// - /// Creates a trust audit event based on the provided decision entry and context information. + /// Creates a trust audit event from a trust decision and operation context. /// - /// - /// - /// - /// - /// + /// Audit operation kind. + /// Decision entry associated with the operation. + /// Human-readable operation reason. + /// Security identifier of the acting user. + /// A populated audit event instance. private static TrustAuditEvent CreateAuditEvent(string eventKind, TrustDecisionEntry entry, string reason, string userSid) { return new TrustAuditEvent @@ -1116,10 +1157,10 @@ private static TrustAuditEvent CreateAuditEvent(string eventKind, TrustDecisionE } /// - /// Computes a SHA256 hash of the serialized audit event for integrity chaining. + /// Computes a SHA256 hash of a serialized audit event for integrity chaining. /// - /// - /// + /// Audit event to hash. + /// Uppercase hexadecimal SHA256 hash, or empty when the input is null. private static string ComputeEventHash(TrustAuditEvent auditEvent) { if (auditEvent == null) @@ -1139,10 +1180,11 @@ private static string ComputeEventHash(TrustAuditEvent auditEvent) } /// - /// Validates the integrity of the audit event chain by ensuring that each event's PreviousEventHash matches the computed hash of the prior event. + /// Validates the integrity of the audit event chain by ensuring each event references + /// the computed hash of its predecessor. /// - /// - /// + /// Audit events in persisted order. + /// Thrown when chain linkage is invalid. private static void ValidateAuditChainIntegrity(IList events) { if (events.Count == 0) @@ -1263,6 +1305,530 @@ private string GetCachedPackageDirectoryHash(string packageDirectoryPath) return hash; } } + + /// + /// Current supported version of persisted trust signature metadata. + /// + private const int TrustSignatureVersion = 1; + + /// + /// Signature algorithm identifier used for trust-store signing. + /// + private const string TrustSignatureAlgorithm = "RSASSA-PKCS1-v1_5-SHA256"; + + /// + /// Logical stream name for trust signature metadata. + /// + private const string TrustSignatureStreamName = "msbuildguard.trust.signature"; + + /// + /// Serialized metadata describing a trust-store asymmetric signature. + /// + private sealed class TrustSignatureRecord + { + /// + /// Metadata schema version. + /// + public int Version { get; set; } = 1; + + /// + /// Signature algorithm identifier. + /// + public string Algorithm { get; set; } = TrustSignatureAlgorithm; + + /// + /// Thumbprint of the certificate that produced the signature. + /// + public string SigningCertificateThumbprint { get; set; } = string.Empty; + + /// + /// Base64 signature payload. + /// + public string Signature { get; set; } = string.Empty; + } + + /// + /// Signs a trust store file using a certificate thumbprint and stores signature metadata next to the trust file. + /// + /// Path to the trust store file. + /// Optional certificate thumbprint override. + public void Sign(string trustPath, string? signingCertificateThumbprint) + { + if (trustPath == null) + { + throw new ArgumentNullException(nameof(trustPath)); + } + + if (!File.Exists(trustPath)) + { + throw new FileNotFoundException("Trust file was not found.", trustPath); + } + + var thumbprint = ResolveSigningCertificateThumbprint(signingCertificateThumbprint); + var certificate = LoadSigningCertificateOrThrow(thumbprint); + var trustBytes = File.ReadAllBytes(trustPath); + var signatureBytes = ComputeSignature(trustBytes, certificate); + var signatureRecord = new TrustSignatureRecord + { + Version = TrustSignatureVersion, + Algorithm = TrustSignatureAlgorithm, + SigningCertificateThumbprint = thumbprint, + Signature = Convert.ToBase64String(signatureBytes) + }; + var signaturePath = GetSignatureStreamPath(trustPath); + var payload = JsonSerializer.Serialize(signatureRecord); + + File.WriteAllText(signaturePath, payload); + } + + /// + /// Validates the asymmetric signature for a trust store file. + /// + /// Path to the trust store file. + public void ValidateSignedTrust(string trustPath) + { + if (trustPath == null) + { + throw new ArgumentNullException(nameof(trustPath)); + } + + if (!File.Exists(trustPath)) + { + throw new FileNotFoundException("Trust file was not found.", trustPath); + } + + if (!TryReadSignatureRecord(trustPath, out var signatureRecord)) + { + throw new InvalidDataException("Trust signature is missing."); + } + + if (signatureRecord.Version != TrustSignatureVersion) + { + throw new InvalidDataException($"Trust signature stream uses unsupported version '{signatureRecord.Version}'."); + } + + if (!string.Equals(signatureRecord.Algorithm, TrustSignatureAlgorithm, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"Trust signature stream uses unsupported algorithm '{signatureRecord.Algorithm}'."); + } + + if (string.IsNullOrWhiteSpace(signatureRecord.Signature)) + { + throw new InvalidDataException("Trust signature is missing."); + } + + if (string.IsNullOrWhiteSpace(signatureRecord.SigningCertificateThumbprint)) + { + throw new InvalidDataException("Trust signature does not declare a signing certificate thumbprint."); + } + + byte[] storedSignatureBytes; + + try + { + storedSignatureBytes = Convert.FromBase64String(signatureRecord.Signature); + } + catch (FormatException ex) + { + throw new InvalidDataException("Trust signature stream contains invalid base64 content.", ex); + } + + var trustedCertificate = LoadTrustedVerificationCertificateOrThrow(signatureRecord.SigningCertificateThumbprint); + var trustBytes = File.ReadAllBytes(trustPath); + var isValid = VerifySignature(trustBytes, storedSignatureBytes, trustedCertificate); + + if (!isValid) + { + throw new InvalidDataException("Trust store signature validation failed."); + } + } + + /// + /// Gets the sidecar signature metadata path for a trust store file. + /// + /// Path to the trust store file. + /// Path of the signature sidecar file. + private string GetSignatureStreamPath(string trustPath) + { + return string.Concat(trustPath, ".signature"); + } + + /// + /// Attempts to read signature metadata for a trust store file. + /// + /// Path to the trust store file. + /// Deserialized signature metadata when available. + /// when metadata exists and is deserialized; otherwise . + private bool TryReadSignatureRecord(string trustPath, out TrustSignatureRecord signatureRecord) + { + signatureRecord = null!; + var signaturePath = GetSignatureStreamPath(trustPath); + + try + { + if (File.Exists(signaturePath)) + { + var signaturePayload = File.ReadAllText(signaturePath); + + signatureRecord = JsonSerializer.Deserialize(signaturePayload) + ?? throw new InvalidDataException("Unable to deserialize trust signature stream content."); + + return true; + } + } + catch + { + } + + return false; + } + + /// + /// Resolves the certificate thumbprint used for signing operations. + /// + /// Caller-supplied thumbprint override. + /// Normalized certificate thumbprint. + private string ResolveSigningCertificateThumbprint(string? providedThumbprint) + { + var thumbprint = string.IsNullOrWhiteSpace(providedThumbprint) + ? Environment.GetEnvironmentVariable("MSBUILDGUARD_POLICY_SIGNING_CERT_THUMBPRINT") ?? string.Empty + : providedThumbprint; + + if (string.IsNullOrWhiteSpace(thumbprint)) + { + throw new InvalidOperationException("Signing certificate thumbprint is required."); + } + + return thumbprint.Replace(" ", string.Empty).ToUpperInvariant(); + } + + /// + /// Loads a signing certificate with private key by thumbprint. + /// + /// Certificate thumbprint. + /// The matching certificate containing a private key. + private X509Certificate2 LoadSigningCertificateOrThrow(string thumbprint) + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + + store.Open(OpenFlags.ReadOnly); + + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + + if (matches.Count > 0 && matches[0].HasPrivateKey) + { + return matches[0]; + } + + using var storeMachine = new X509Store(StoreName.My, StoreLocation.LocalMachine); + + storeMachine.Open(OpenFlags.ReadOnly); + + var matchesMachine = storeMachine.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + + if (matchesMachine.Count > 0 && matchesMachine[0].HasPrivateKey) + { + return matchesMachine[0]; + } + + throw new InvalidOperationException($"Signing certificate '{thumbprint}' was not found with private key."); + } + + /// + /// Loads a trusted verification certificate by thumbprint, including optional root CA pin validation. + /// + /// Certificate thumbprint. + /// The matching verification certificate. + private X509Certificate2 LoadTrustedVerificationCertificateOrThrow(string thumbprint) + { + using var store = new X509Store(StoreName.TrustedPeople, StoreLocation.LocalMachine); + + store.Open(OpenFlags.ReadOnly); + + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + + if (matches.Count > 0) + { + // Optional Root CA Pinning verification + var pinnedCa = Environment.GetEnvironmentVariable("MSBUILDGUARD_ROOT_CA_THUMBPRINT"); + + if (!string.IsNullOrWhiteSpace(pinnedCa)) + { + var normalizedPinnedCa = pinnedCa!.Replace(" ", string.Empty).ToUpperInvariant(); + var chain = new X509Chain(); + + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + chain.Build(matches[0]); + + var isChainValid = false; + + foreach (var element in chain.ChainElements) + { + var elementThumb = element.Certificate.Thumbprint.Replace(" ", string.Empty).ToUpperInvariant(); + + if (string.Equals(elementThumb, normalizedPinnedCa, StringComparison.OrdinalIgnoreCase)) + { + isChainValid = true; + + break; + } + } + + if (!isChainValid) + { + throw new InvalidDataException($"The verification certificate is not issued by the trusted Root CA '{normalizedPinnedCa}'."); + } + } + + return matches[0]; + } + + var allowCurrentUser = Environment.GetEnvironmentVariable("MSBUILDGUARD_POLICY_ALLOW_CURRENTUSER_TRUSTED_STORE"); + + if (string.Equals(allowCurrentUser, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(allowCurrentUser, "1")) + { + using var storeUser = new X509Store(StoreName.TrustedPeople, StoreLocation.CurrentUser); + + storeUser.Open(OpenFlags.ReadOnly); + + var matchesUser = storeUser.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + + if (matchesUser.Count > 0) + { + return matchesUser[0]; + } + } + + throw new InvalidDataException($"Trusted verification certificate '{thumbprint}' was not found."); + } + + /// + /// Computes an RSA PKCS#1 v1.5 SHA-256 signature over the supplied payload. + /// + /// Payload bytes to sign. + /// Certificate containing the signing private key. + /// Signature bytes. + private static byte[] ComputeSignature(byte[] payload, X509Certificate2 certificate) + { + using var rsa = certificate.GetRSAPrivateKey(); + + if (rsa == null) + { + throw new InvalidOperationException("Certificate does not provide an RSA private key."); + } + + return rsa.SignData(payload, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + + /// + /// Verifies an RSA PKCS#1 v1.5 SHA-256 signature. + /// + /// Original payload bytes. + /// Signature bytes. + /// Certificate containing the verification public key. + /// when the signature is valid; otherwise . + private static bool VerifySignature(byte[] payload, byte[] signature, X509Certificate2 certificate) + { + using var rsa = certificate.GetRSAPublicKey(); + + if (rsa == null) + { + throw new InvalidDataException("Certificate does not provide an RSA public key."); + } + + return rsa.VerifyData(payload, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + } + + /// + /// Gets or creates the local DPAPI-protected symmetric key used for trust-store envelope signing. + /// + /// Base64 key material, or fallback static key when key access fails. + private string GetLocalDPAPIKey() + { + try + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var keyDir = Path.Combine(appData, "MSBuildGuard"); + var keyPath = Path.Combine(keyDir, "machine.key"); + + if (!Directory.Exists(keyDir)) + { + Directory.CreateDirectory(keyDir); + } + + if (File.Exists(keyPath)) + { + var encrypted = File.ReadAllBytes(keyPath); + var decrypted = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser); + + return Encoding.UTF8.GetString(decrypted); + } + else + { + var keyBytes = new byte[32]; + + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(keyBytes); + } + + var keyString = Convert.ToBase64String(keyBytes); + var rawKeyBytes = Encoding.UTF8.GetBytes(keyString); + var encrypted = ProtectedData.Protect(rawKeyBytes, null, DataProtectionScope.CurrentUser); + + File.WriteAllBytes(keyPath, encrypted); + + return keyString; + } + } + catch (Exception) + { + return TrustStoreSigningKey; + } + } + + /// + /// Determines whether a trust-store path corresponds to the default user-level trust store. + /// + /// Trust store path. + /// when the path is the default user trust store path; otherwise . + private bool IsUserTrustPath(string path) + { + try + { + var userDefaultPath = GetDefaultUserTrustPath(); + + return string.Equals(Path.GetFullPath(path), Path.GetFullPath(userDefaultPath), StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + /// + /// Resolves the symmetric signing key for a trust-store path. + /// + /// Trust store path. + /// Resolved signing key. + private string GetSigningKeyForPath(string path) + { + if (IsUserTrustPath(path)) + { + return GetLocalDPAPIKey(); + } + + if (CoreSettings.AllowSharingTrustsInRepositories) + { + return TrustStoreSigningKey; + } + + return GetLocalDPAPIKey(); + } + + /// + /// Pins a repository directory so future trust loads require asymmetric signatures. + /// + /// Trust store path used to determine repository directory. + private void PinRepositoryAsAsymmetricRequired(string path) + { + try + { + var solutionDir = Path.GetDirectoryName(path); + + if (string.IsNullOrWhiteSpace(solutionDir)) + { + return; + } + + var pinned = LoadPinnedRepositories(); + + if (!pinned.Contains(solutionDir, StringComparer.OrdinalIgnoreCase)) + { + pinned.Add(solutionDir); + SavePinnedRepositories(pinned); + } + } + catch + { + } + } + + /// + /// Determines whether a repository directory is pinned to require asymmetric trust signatures. + /// + /// Trust store path used to determine repository directory. + /// when the repository is pinned; otherwise . + private bool IsRepositoryPinnedAsAsymmetricRequired(string path) + { + try + { + var solutionDir = Path.GetDirectoryName(path); + + if (string.IsNullOrWhiteSpace(solutionDir)) + { + return false; + } + + var pinned = LoadPinnedRepositories(); + + return pinned.Contains(solutionDir, StringComparer.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + /// + /// Loads the persisted list of repositories pinned for asymmetric-signature enforcement. + /// + /// List of pinned repository directories. + private List LoadPinnedRepositories() + { + try + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var pinPath = Path.Combine(appData, "MSBuildGuard", "pinned.bson"); + + if (File.Exists(pinPath)) + { + var encrypted = File.ReadAllBytes(pinPath); + var decrypted = ProtectedData.Unprotect(encrypted, null, DataProtectionScope.CurrentUser); + var json = Encoding.UTF8.GetString(decrypted); + + return JsonSerializer.Deserialize>(json) ?? new List(); + } + } + catch + { + } + + return new List(); + } + + /// + /// Persists the list of repositories pinned for asymmetric-signature enforcement. + /// + /// Pinned repository directories. + private void SavePinnedRepositories(List pinned) + { + try + { + var json = JsonSerializer.Serialize(pinned); + var rawBytes = Encoding.UTF8.GetBytes(json); + var encrypted = ProtectedData.Protect(rawBytes, null, DataProtectionScope.CurrentUser); + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var pinPath = Path.Combine(appData, "MSBuildGuard", "pinned.bson"); + + File.WriteAllBytes(pinPath, encrypted); + } + catch + { + } + } } } + diff --git a/MSBuildGuard.VSCode/package-lock.json b/MSBuildGuard.VSCode/package-lock.json index b6bde5e..e1443f7 100644 --- a/MSBuildGuard.VSCode/package-lock.json +++ b/MSBuildGuard.VSCode/package-lock.json @@ -1,12 +1,12 @@ { "name": "msbuildguard", - "version": "0.0.7", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "msbuildguard", - "version": "0.0.7", + "version": "0.1.0", "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 6cea3e6..6fd2ee5 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.0.7", + "version": "0.1.0", "preview": true, "publisher": "Hefaistos68", "author": { @@ -81,6 +81,21 @@ "command": "msbuildguard.manageSignerTrusts", "category": "MSBuild Guard", "title": "Manage Trusted Certificate Signers..." + }, + { + "command": "msbuildguard.removeAllSolutionTrusts", + "category": "MSBuild Guard", + "title": "Remove All Solution Trusts" + }, + { + "command": "msbuildguard.removeAllProjectTrusts", + "category": "MSBuild Guard", + "title": "Remove All Project Trusts" + }, + { + "command": "msbuildguard.removeAllUserTrusts", + "category": "MSBuild Guard", + "title": "Remove All User Trusts" } ], "menus": { @@ -101,6 +116,22 @@ "default": true, "description": "Intelligently suggests baseline trust settings when loading a new workspace for the first time." }, + "msbuildguard.enforceAsymmetricSignatures": { + "type": "boolean", + "default": false, + "description": "Strictly enforces asymmetric certificate-based signature verification for trust stores and policy documents." + }, + "msbuildguard.trustManagement.allowSharingTrustsInRepositories": { + "type": "boolean", + "default": false, + "description": "Allows trust settings to be shared in repository files. Only available when asymmetric certificate key management is configured." + }, + "msbuildguard.trustManagement.keyManagementMode": { + "type": "string", + "default": "unconfigured", + "enum": ["unconfigured", "dpapi", "certificates"], + "description": "Specifies the mode used for signing and validating trust files. Local DPAPI uses local machine-specific keys, disabling repository sharing." + }, "msbuildguard.autoOpenSecurityReview": { "type": "boolean", "default": true, diff --git a/MSBuildGuard.VSCode/src/extension.ts b/MSBuildGuard.VSCode/src/extension.ts index f7e181f..2090d95 100644 --- a/MSBuildGuard.VSCode/src/extension.ts +++ b/MSBuildGuard.VSCode/src/extension.ts @@ -94,6 +94,87 @@ export function activate(context: vscode.ExtensionContext) { }) ); + context.subscriptions.push( + vscode.commands.registerCommand('msbuildguard.removeAllSolutionTrusts', async () => { + await removeAllSolutionTrusts(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('msbuildguard.removeAllProjectTrusts', async () => { + await removeAllProjectTrusts(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('msbuildguard.removeAllUserTrusts', async () => { + await removeAllUserTrusts(); + }) + ); + + const config = vscode.workspace.getConfiguration('msbuildguard'); + const currentEnforce = config.get('enforceAsymmetricSignatures', false); + void context.globalState.update('lastEnforceAsymmetricSignatures', currentEnforce); + + const keyMode = config.get('trustManagement.keyManagementMode', 'unconfigured'); + if (keyMode === 'unconfigured') { + void showFirstRunQuickPick(config); + } + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(async (e) => { + if (e.affectsConfiguration('msbuildguard.enforceAsymmetricSignatures')) { + const conf = vscode.workspace.getConfiguration('msbuildguard'); + const newValue = conf.get('enforceAsymmetricSignatures', false); + const oldValue = context.globalState.get('lastEnforceAsymmetricSignatures', false); + + if (oldValue === true && newValue === false) { + const confirm = await vscode.window.showWarningMessage( + "Downgrading security settings: Disabling strict asymmetric signatures will permanently delete all local user, solution, and project trust files. Do you want to proceed?", + { modal: true }, + "Yes", + "No" + ); + + if (confirm === "Yes") { + await purgeAllTrusts(context); + restartWorkerClient(); + void runScan(); + } else { + await conf.update('enforceAsymmetricSignatures', true, vscode.ConfigurationTarget.Global); + } + } else { + restartWorkerClient(); + void runScan(); + } + await context.globalState.update('lastEnforceAsymmetricSignatures', newValue); + } else if (e.affectsConfiguration('msbuildguard.trustManagement.allowSharingTrustsInRepositories')) { + const conf = vscode.workspace.getConfiguration('msbuildguard'); + const allowSharing = conf.get('trustManagement.allowSharingTrustsInRepositories', false); + const mode = conf.get('trustManagement.keyManagementMode', 'unconfigured'); + if (allowSharing && mode === 'dpapi') { + void vscode.window.showWarningMessage("Cannot enable repository trust sharing while key management is set to Solo Developer (Local DPAPI)."); + await conf.update('trustManagement.allowSharingTrustsInRepositories', false, vscode.ConfigurationTarget.Global); + } else { + restartWorkerClient(); + void runScan(); + } + } else if (e.affectsConfiguration('msbuildguard.trustManagement.keyManagementMode')) { + const conf = vscode.workspace.getConfiguration('msbuildguard'); + const mode = conf.get('trustManagement.keyManagementMode', 'unconfigured'); + if (mode === 'dpapi') { + const allowSharing = conf.get('trustManagement.allowSharingTrustsInRepositories', false); + if (allowSharing) { + void vscode.window.showWarningMessage("In Solo Developer (Local DPAPI) mode, sharing trusts in repositories is not supported. Disabling repository trust sharing."); + await conf.update('trustManagement.allowSharingTrustsInRepositories', false, vscode.ConfigurationTarget.Global); + } + } + restartWorkerClient(); + void runScan(); + } + }) + ); + // Watchers for NuGet restores and Policy modifications setupFileSystemWatchers(context); @@ -474,3 +555,307 @@ async function manageTrusts(initialScope: 'User' | 'Solution' | 'Project' = 'Use initialScope ); } + +// Security Hardening Helper functions + +function getUserTrustPath(): string { + const localAppData = process.env.LOCALAPPDATA || + (process.platform === 'darwin' ? path.join(process.env.HOME || '', 'Library', 'Caches') : path.join(process.env.HOME || '', '.local', 'share')); + return path.join(localAppData, 'MSBuildGuard', 'trust.json'); +} + +function getRecentWorkspaces(): string[] { + const paths: string[] = []; + try { + const appData = process.env.APPDATA || + (process.platform === 'darwin' ? path.join(process.env.HOME || '', 'Library', 'Application Support') : path.join(process.env.HOME || '', '.config')); + const channelDirs = ['Code', 'Code - Insiders', 'VSCodium']; + for (const dir of channelDirs) { + const storagePath = path.join(appData, dir, 'User', 'globalStorage', 'storage.json'); + if (fs.existsSync(storagePath)) { + const content = fs.readFileSync(storagePath, 'utf8'); + const data = JSON.parse(content); + if (data.openedPathsList && Array.isArray(data.openedPathsList.entries)) { + for (const entry of data.openedPathsList.entries) { + if (entry.folderUri) { + try { + const uriPath = vscode.Uri.parse(entry.folderUri).fsPath; + if (uriPath) { + paths.push(uriPath); + } + } catch {} + } else if (entry.workspace && entry.workspace.configPath) { + try { + const uriPath = vscode.Uri.parse(entry.workspace.configPath).fsPath; + const folder = path.dirname(uriPath); + paths.push(folder); + } catch {} + } + } + } + } + } + } catch (e) { + } + return paths; +} + +function purgeTrustFilesInDir(dir: string, depth: number = 0): void { + if (depth > 5) { + return; + } + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + if (file === '.msbuildguard') { + const trustFile = path.join(fullPath, 'trust.json'); + if (fs.existsSync(trustFile)) { + try { + fs.unlinkSync(trustFile); + outputChannel?.appendLine(`Deleted trust store: ${trustFile}`); + const sigFile = trustFile + '.signature'; + if (fs.existsSync(sigFile)) { + fs.unlinkSync(sigFile); + outputChannel?.appendLine(`Deleted signature companion: ${sigFile}`); + } + } catch (e: any) { + outputChannel?.appendLine(`Failed to delete ${trustFile}: ${e.message}`); + } + } + } else { + try { + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + if (file !== 'node_modules' && file !== '.git' && file !== 'bin' && file !== 'obj') { + purgeTrustFilesInDir(fullPath, depth + 1); + } + } + } catch (e) {} + } + } + } catch (e) {} +} + +async function purgeAllTrusts(context: vscode.ExtensionContext): Promise { + outputChannel?.appendLine('Purging all trust stores due to enforceAsymmetricSignatures downgrade...'); + + // 1. Delete user-level trust store + const userPath = getUserTrustPath(); + if (fs.existsSync(userPath)) { + try { + fs.unlinkSync(userPath); + outputChannel?.appendLine(`Deleted user trust store: ${userPath}`); + const sigPath = userPath + '.signature'; + if (fs.existsSync(sigPath)) { + fs.unlinkSync(sigPath); + outputChannel?.appendLine(`Deleted user trust signature: ${sigPath}`); + } + } catch (e: any) { + outputChannel?.appendLine(`Failed to delete user trust store: ${e.message}`); + } + } + + // 2. Scan recent and open workspace folders to delete .msbuildguard/trust.json + const foldersToScan = new Set(); + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + for (const folder of workspaceFolders) { + foldersToScan.add(folder.uri.fsPath); + } + } + + const recent = getRecentWorkspaces(); + for (const folder of recent) { + foldersToScan.add(folder); + } + + for (const folder of foldersToScan) { + if (fs.existsSync(folder)) { + try { + purgeTrustFilesInDir(folder); + } catch (e: any) { + outputChannel?.appendLine(`Failed to purge trusts in folder ${folder}: ${e.message}`); + } + } + } + + void vscode.window.showInformationMessage("All local, solution, and project trust stores have been successfully purged."); +} + +function restartWorkerClient(): void { + if (workerClient) { + workerClient.dispose(); + } + if (extensionContext) { + try { + workerClient = new WorkerClient(extensionContext); + outputChannel?.appendLine('MSBuild Guard C# background worker restarted.'); + } catch (err: any) { + outputChannel?.appendLine(`Failed to launch background worker: ${err.message}`); + void vscode.window.showErrorMessage(`MSBuild Guard failed to activate background worker: ${err.message}`); + } + } +} + +async function showFirstRunQuickPick(config: vscode.WorkspaceConfiguration): Promise { + const selected = await vscode.window.showQuickPick([ + { label: "Solo Developer (Local DPAPI)", description: "Keys are unique to this machine, secured via DPAPI. Sharing trusts in repositories is disabled.", value: "dpapi" }, + { label: "Team Environment (Asymmetric Certificates)", description: "Enables sharing signed trusts. Requires public validation certificates.", value: "certificates" } + ], { + placeHolder: "MSBuild Guard: Choose Key Management Mode", + ignoreFocusOut: true + }); + + if (selected) { + await config.update('trustManagement.keyManagementMode', selected.value, vscode.ConfigurationTarget.Global); + if (selected.value === 'dpapi') { + await config.update('trustManagement.allowSharingTrustsInRepositories', false, vscode.ConfigurationTarget.Global); + } + } +} + +async function removeAllSolutionTrusts(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + void vscode.window.showWarningMessage('No active workspace folders loaded.'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + "Are you sure you want to permanently remove all solution-level trusts for this workspace?", + { modal: true }, + "Yes", + "No" + ); + + if (confirm !== "Yes") { + return; + } + + const solutionTrustPath = path.join(workspaceFolders[0].uri.fsPath, '.msbuildguard', 'trust.json'); + if (fs.existsSync(solutionTrustPath)) { + try { + fs.unlinkSync(solutionTrustPath); + outputChannel?.appendLine(`Deleted solution trust file: ${solutionTrustPath}`); + const sigPath = solutionTrustPath + '.signature'; + if (fs.existsSync(sigPath)) { + fs.unlinkSync(sigPath); + outputChannel?.appendLine(`Deleted solution trust signature: ${sigPath}`); + } + void vscode.window.showInformationMessage("Solution trusts successfully removed."); + restartWorkerClient(); + void runScan(); + } catch (e: any) { + void vscode.window.showErrorMessage(`Failed to remove solution trusts: ${e.message}`); + } + } else { + void vscode.window.showInformationMessage("No solution trust file found to remove."); + } +} + +async function removeAllUserTrusts(): Promise { + const confirm = await vscode.window.showWarningMessage( + "Are you sure you want to permanently remove all user-level trusts?", + { modal: true }, + "Yes", + "No" + ); + + if (confirm !== "Yes") { + return; + } + + const userPath = getUserTrustPath(); + if (fs.existsSync(userPath)) { + try { + fs.unlinkSync(userPath); + outputChannel?.appendLine(`Deleted user trust store: ${userPath}`); + const sigPath = userPath + '.signature'; + if (fs.existsSync(sigPath)) { + fs.unlinkSync(sigPath); + outputChannel?.appendLine(`Deleted user trust signature: ${sigPath}`); + } + void vscode.window.showInformationMessage("User-level trusts successfully removed."); + restartWorkerClient(); + void runScan(); + } catch (e: any) { + void vscode.window.showErrorMessage(`Failed to remove user trusts: ${e.message}`); + } + } else { + void vscode.window.showInformationMessage("No user trust file found to remove."); + } +} + +async function removeAllProjectTrusts(): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + void vscode.window.showWarningMessage('No active workspace folders loaded.'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + "Are you sure you want to permanently remove all project-level trusts for this workspace?", + { modal: true }, + "Yes", + "No" + ); + + if (confirm !== "Yes") { + return; + } + + const rootDir = workspaceFolders[0].uri.fsPath; + let deletedCount = 0; + + function purgeProjectTrusts(dir: string, depth: number = 0): void { + if (depth > 5) { + return; + } + try { + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + if (file === '.msbuildguard') { + if (path.resolve(dir) === path.resolve(rootDir)) { + continue; + } + const trustFile = path.join(fullPath, 'trust.json'); + if (fs.existsSync(trustFile)) { + try { + fs.unlinkSync(trustFile); + deletedCount++; + outputChannel?.appendLine(`Deleted project trust store: ${trustFile}`); + const sigFile = trustFile + '.signature'; + if (fs.existsSync(sigFile)) { + fs.unlinkSync(sigFile); + outputChannel?.appendLine(`Deleted project trust signature: ${sigFile}`); + } + } catch (e: any) { + outputChannel?.appendLine(`Failed to delete ${trustFile}: ${e.message}`); + } + } + } else { + try { + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + if (file !== 'node_modules' && file !== '.git' && file !== 'bin' && file !== 'obj') { + purgeProjectTrusts(fullPath, depth + 1); + } + } + } catch (e) {} + } + } + } catch (e) {} + } + + purgeProjectTrusts(rootDir); + if (deletedCount > 0) { + void vscode.window.showInformationMessage(`Successfully removed project-level trusts from ${deletedCount} project(s).`); + restartWorkerClient(); + void runScan(); + } else { + void vscode.window.showInformationMessage("No project-level trust files found to remove."); + } +} diff --git a/MSBuildGuard.VSCode/src/services/workerClient.ts b/MSBuildGuard.VSCode/src/services/workerClient.ts index 6c7475f..2b3e084 100644 --- a/MSBuildGuard.VSCode/src/services/workerClient.ts +++ b/MSBuildGuard.VSCode/src/services/workerClient.ts @@ -113,9 +113,18 @@ export class WorkerClient implements vscode.Disposable { const packagedWorkerDll = path.resolve(context.extensionPath, 'dist', 'worker', 'MSBuildGuard.Worker.dll'); const workerProject = path.resolve(context.extensionPath, '..', 'MSBuildGuard.Worker', 'MSBuildGuard.Worker.csproj'); const workerArgs = this.getWorkerLaunchArguments(packagedWorkerDll, workerProject); + const config = vscode.workspace.getConfiguration('msbuildguard'); + const enforceAsymmetric = config.get('enforceAsymmetricSignatures', false); + const allowSharing = config.get('trustManagement.allowSharingTrustsInRepositories', false); + const spawnEnv = { + ...process.env, + MSBUILDGUARD_ENFORCE_ASYMMETRIC_SIGNATURES: String(enforceAsymmetric), + MSBUILDGUARD_ALLOW_SHARING_TRUSTS: String(allowSharing) + }; this.process = cp.spawn('dotnet', workerArgs, { - stdio: ['pipe', 'pipe', 'pipe'] + stdio: ['pipe', 'pipe', 'pipe'], + env: spawnEnv }); this.process.stdout.on('data', (data: Buffer) => { diff --git a/MSBuildGuard.VSCode/src/views/onboardingView.ts b/MSBuildGuard.VSCode/src/views/onboardingView.ts index 3c125b2..6aa4cb4 100644 --- a/MSBuildGuard.VSCode/src/views/onboardingView.ts +++ b/MSBuildGuard.VSCode/src/views/onboardingView.ts @@ -565,6 +565,13 @@ export class OnboardingPanel { color: var(--text-secondary); font-size: 0.9rem; } + + #content { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + } diff --git a/MSBuildGuard.VSCode/src/views/securityReviewView.ts b/MSBuildGuard.VSCode/src/views/securityReviewView.ts index 5cc9c9b..c6338b4 100644 --- a/MSBuildGuard.VSCode/src/views/securityReviewView.ts +++ b/MSBuildGuard.VSCode/src/views/securityReviewView.ts @@ -50,6 +50,9 @@ export class SecurityReviewViewProvider implements vscode.WebviewViewProvider { const config = vscode.workspace.getConfiguration('msbuildguard'); void config.update('onlyUntrustedIssues', data.value, vscode.ConfigurationTarget.Workspace); break; + case 'removeAllProjectTrusts': + void vscode.commands.executeCommand('msbuildguard.removeAllProjectTrusts'); + break; } }); @@ -490,6 +493,9 @@ export class SecurityReviewViewProvider implements vscode.WebviewViewProvider { +
+ +
@@ -532,6 +538,20 @@ export class SecurityReviewViewProvider implements vscode.WebviewViewProvider { let filterOnlyUntrusted = ${onlyUntrusted}; let overallAction = 'Allow'; + function updateProjectTrustsButtonState() { + const btn = document.getElementById('btnRemoveProjectTrusts'); + if (btn) { + btn.disabled = filterOnlyUntrusted; + if (filterOnlyUntrusted) { + btn.style.opacity = '0.5'; + btn.style.cursor = 'not-allowed'; + } else { + btn.style.opacity = '1'; + btn.style.cursor = 'pointer'; + } + } + } + function getSeverityRisk(severity) { switch (severity.toLowerCase()) { case 'critical': return 100; @@ -566,10 +586,14 @@ export class SecurityReviewViewProvider implements vscode.WebviewViewProvider { } else if (message.command === 'updateOnlyUntrustedSetting') { filterOnlyUntrusted = message.value; untrustedFilterEl.checked = filterOnlyUntrusted; + updateProjectTrustsButtonState(); applyFiltersAndRender(); } }); + // Initialize state + setTimeout(updateProjectTrustsButtonState, 50); + document.getElementById('btnScan').addEventListener('click', () => { vscode.postMessage({ command: 'scanWorkspace' }); }); @@ -603,9 +627,14 @@ export class SecurityReviewViewProvider implements vscode.WebviewViewProvider { untrustedFilterEl.addEventListener('change', (e) => { filterOnlyUntrusted = e.target.checked; vscode.postMessage({ command: 'saveOnlyUntrustedSetting', value: filterOnlyUntrusted }); + updateProjectTrustsButtonState(); applyFiltersAndRender(); }); + document.getElementById('btnRemoveProjectTrusts').addEventListener('click', () => { + vscode.postMessage({ command: 'removeAllProjectTrusts' }); + }); + function renderReport(report) { overallAction = report.recommendedAction; projectFilterEl.innerHTML = ''; diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/KeyManagementOnboardingViewModelTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/KeyManagementOnboardingViewModelTests.cs new file mode 100644 index 0000000..be9d941 --- /dev/null +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/KeyManagementOnboardingViewModelTests.cs @@ -0,0 +1,38 @@ +using MSBuildGuard.VisualStudio.Options; +using MSBuildGuard.VisualStudio.ToolWindows; +using NUnit.Framework; +using Shouldly; + +namespace MSBuildGuard.VisualStudio.ToolWindows.Tests +{ + /// + /// Tests for . + /// + [TestFixture] + public sealed class KeyManagementOnboardingViewModelTests + { + /// + /// Verifies that the view model initializes with SelectedMode set to Unconfigured. + /// + [Test] + public void Constructor_ShouldInitializeWithUnconfiguredMode() + { + var viewModel = new KeyManagementOnboardingViewModel(); + + viewModel.SelectedMode.ShouldBe(KeyManagementModeKind.Unconfigured); + } + + /// + /// Verifies that SelectedMode can be changed and retrieved successfully. + /// + [Test] + public void SelectedMode_ShouldStoreAndRetrieveValue() + { + var viewModel = new KeyManagementOnboardingViewModel(); + + viewModel.SelectedMode = KeyManagementModeKind.DPAPI; + + viewModel.SelectedMode.ShouldBe(KeyManagementModeKind.DPAPI); + } + } +} diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageAssemblyTrustsHelperTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageAssemblyTrustsHelperTests.cs index 31881d3..255a463 100644 --- a/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageAssemblyTrustsHelperTests.cs +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageAssemblyTrustsHelperTests.cs @@ -198,7 +198,9 @@ public void LoadTrustedAssemblies_LoadsAssembliesFromStore() ] }"; - File.WriteAllText(trustStorePath, json); + var doc = System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + new TrustStoreService().Save(trustStorePath, doc!); var helper = new ManageAssemblyTrustsHelper(string.Empty, projectPath); @@ -283,7 +285,9 @@ public void MoveTrustToScope_RelocatesDecisionToAnotherStore() ] }"; - File.WriteAllText(trustStorePath, json); + var doc = System.Text.Json.JsonSerializer.Deserialize(json, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + new TrustStoreService().Save(trustStorePath, doc!); helper.LoadTrustedAssemblies(TrustScope.Project, projectAPath); diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManagePackageTrustsHelperTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManagePackageTrustsHelperTests.cs index d570e32..97f902f 100644 --- a/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManagePackageTrustsHelperTests.cs +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManagePackageTrustsHelperTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using MSBuildGuard.Core.Trust; using MSBuildGuard.VisualStudio.Models; using MSBuildGuard.VisualStudio.ToolWindows; @@ -214,7 +215,8 @@ public void LoadTrustedPackages_LoadsPackagesFromStore() ] }"; - File.WriteAllText(trustStorePath, json); + var doc = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }); + new TrustStoreService().Save(trustStorePath, doc!); var helper = new ManagePackageTrustsHelper(string.Empty, projectPath); diff --git a/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageSignerTrustsHelperTests.cs b/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageSignerTrustsHelperTests.cs index 9ecc9be..ac72ba2 100644 --- a/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageSignerTrustsHelperTests.cs +++ b/MSBuildGuard.VisualStudio.Tests/ToolWindows/ManageSignerTrustsHelperTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using MSBuildGuard.Core.Trust; using MSBuildGuard.VisualStudio.Models; using MSBuildGuard.VisualStudio.ToolWindows; @@ -172,7 +173,8 @@ public void LoadTrustedSigners_LoadsSignersFromStore() ] }"; - File.WriteAllText(trustStorePath, json); + var doc = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }); + new TrustStoreService().Save(trustStorePath, doc!); var helper = new ManageSignerTrustsHelper(string.Empty, projectPath); @@ -241,7 +243,8 @@ public void Save_WritesTrustedSignersToStore() ] }"; - File.WriteAllText(trustStorePath, json); + var doc = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }); + new TrustStoreService().Save(trustStorePath, doc!); helper.LoadTrustedSigners(TrustScope.Project, projectPath); @@ -291,7 +294,8 @@ public void MoveTrustToScope_RelocatesDecisionToAnotherStore() ] }"; - File.WriteAllText(trustStorePath, json); + var doc = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }); + new TrustStoreService().Save(trustStorePath, doc!); helper.LoadTrustedSigners(TrustScope.Project, projectAPath); diff --git a/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj b/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj index 13c7331..5d329bb 100644 --- a/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj +++ b/MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj @@ -37,7 +37,7 @@ MSBuildGuard.VisualStudio MSBuildGuard MSBuildGuard - 0.2.7 + 0.3.0 False Hefaistos68 Hefaistos68.dev diff --git a/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs b/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs index 37e06e7..c155199 100644 --- a/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs +++ b/MSBuildGuard.VisualStudio/MSBuildGuardPackage.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.Design; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -88,6 +89,11 @@ public sealed class MSBuildGuardPackage : AsyncPackage ///
private bool isInitialized; + /// + /// Tracks the last known EnforceAsymmetricSignatures setting state to detect downgrades. + /// + private bool lastEnforceAsymmetric; + /// /// Tracks solution paths for which baseline onboarding has been prompted during this session. /// @@ -134,6 +140,33 @@ internal Services.VisualStudioUiFeedbackService UiFeedbackService internal void NotifyOptionsChanged() { this.unifiedSettingsOptionsProvider.NotifyChanged(); + + var page = (MSBuildGuardOptionsPage)this.GetDialogPage(typeof(MSBuildGuardOptionsPage)); + var currentEnforce = page.EnforceAsymmetricSignatures; + + if (this.lastEnforceAsymmetric && !currentEnforce) + { + var confirm = MessageBox.Show( + "Downgrading security settings: Disabling strict asymmetric signatures will permanently delete all local user, solution, and project trust files on this machine. Do you want to proceed?", + "MSBuild Guard - Warning", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (confirm == MessageBoxResult.Yes) + { + this.JoinableTaskFactory.Run(async delegate + { + await this.PurgeAllTrustsAsync().ConfigureAwait(false); + }); + } + else + { + page.EnforceAsymmetricSignatures = true; + page.SaveSettingsToStorage(); + } + } + + this.lastEnforceAsymmetric = page.EnforceAsymmetricSignatures; } /// @@ -214,6 +247,18 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke await Commands.ManageSignerTrustsCommand.InitializeAsync(this); await Commands.ManagePackageTrustsCommand.InitializeAsync(this); + var commandService = await this.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService; + + if (commandService != null) + { + this.RegisterHardeningCommands(commandService); + } + + var page = (MSBuildGuardOptionsPage)this.GetDialogPage(typeof(MSBuildGuardOptionsPage)); + + PerformOnboardingCheck(page); + this.lastEnforceAsymmetric = page.EnforceAsymmetricSignatures; + this.shieldStatusBarControl = new Services.ShieldStatusBarControl(this); this.shieldStatusBarControl.UpdateState(this.latestScanReport); _ = Services.StatusBarInjector.InjectControlAsync(this.shieldStatusBarControl); @@ -1230,5 +1275,430 @@ private static void WriteEmptyTrustStore(string solutionTrustPath) return candidate.CompletedAtUtc >= current.CompletedAtUtc ? candidate : current; } + + /// + /// Registers the hardening commands with the Visual Studio OleMenuCommandService. + /// + /// Command service. + private void RegisterHardeningCommands(OleMenuCommandService commandService) + { + var removeSolutionId = new CommandID(new Guid(PackageGuids.CommandSetString), PackageIds.RemoveAllSolutionTrustsCommandId); + var removeSolutionItem = new OleMenuCommand(this.ExecuteRemoveAllSolutionTrusts, removeSolutionId); + + removeSolutionItem.BeforeQueryStatus += (s, e) => + { + ThreadHelper.ThrowIfNotOnUIThread(); + + if (s is OleMenuCommand menuCmd) + { + menuCmd.Visible = true; + menuCmd.Enabled = Services.SolutionDiscoveryService.HasOpenSolution(); + } + }; + + commandService.AddCommand(removeSolutionItem); + + var removeUserId = new CommandID(new Guid(PackageGuids.CommandSetString), PackageIds.RemoveAllUserTrustsCommandId); + var removeUserItem = new OleMenuCommand(this.ExecuteRemoveAllUserTrusts, removeUserId); + + commandService.AddCommand(removeUserItem); + } + + /// + /// Prompts the user to configure key management mode on the first run of the extension. + /// + /// Options page. + private static void PerformOnboardingCheck(MSBuildGuardOptionsPage page) + { + if (page.KeyManagementMode != KeyManagementModeKind.Unconfigured) + { + return; + } + + if (System.Windows.Application.Current == null) + { + return; + } + + var mainWindow = System.Windows.Application.Current.MainWindow; + + if (mainWindow != null && mainWindow.IsVisible) + { + ShowOnboardingDialog(page); + } + else if (mainWindow != null) + { + DependencyPropertyChangedEventHandler? handler = null; + + handler = (s, e) => + { + if (mainWindow.IsVisible) + { + mainWindow.IsVisibleChanged -= handler; + ShowOnboardingDialog(page); + } + }; + + mainWindow.IsVisibleChanged += handler; + } + else + { + EventHandler? activatedHandler = null; + + activatedHandler = (s, e) => + { + var mainWin = System.Windows.Application.Current.MainWindow; + + if (mainWin != null && mainWin.IsVisible) + { + System.Windows.Application.Current.Activated -= activatedHandler; + ShowOnboardingDialog(page); + } + }; + + System.Windows.Application.Current.Activated += activatedHandler; + } + } + + /// + /// Displays the key management onboarding dialog and saves the user choice. + /// + /// Options page. + private static void ShowOnboardingDialog(MSBuildGuardOptionsPage page) + { + if (page.KeyManagementMode != KeyManagementModeKind.Unconfigured) + { + return; + } + + var viewModel = new ToolWindows.KeyManagementOnboardingViewModel(); + var dialog = new ToolWindows.KeyManagementOnboardingDialog(viewModel) + { + Owner = System.Windows.Application.Current.MainWindow, + WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner + }; + + var result = dialog.ShowDialog(); + + if (result == true) + { + page.KeyManagementMode = viewModel.SelectedMode; + + if (viewModel.SelectedMode == KeyManagementModeKind.DPAPI) + { + page.AllowSharingTrustsInRepositories = false; + } + + page.SaveSettingsToStorage(); + } + } + + /// + /// Handles execution of the Remove All Solution Trusts command. + /// + /// Event sender. + /// Event arguments. + private void ExecuteRemoveAllSolutionTrusts(object? sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var result = MessageBox.Show( + "Are you sure you want to permanently remove all solution-level trusts for this solution?", + "MSBuild Guard - Confirm", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result == MessageBoxResult.Yes) + { + this.JoinableTaskFactory.Run(async delegate + { + await this.RemoveSolutionTrustsInternalAsync().ConfigureAwait(false); + }); + } + } + + /// + /// Internal helper to delete solution trust files and trigger a scan. + /// + /// A task that completes when deletion is finished. + private async Task RemoveSolutionTrustsInternalAsync() + { + await this.JoinableTaskFactory.SwitchToMainThreadAsync(this.DisposalToken); + + var dte = await this.GetServiceAsync(typeof(SDTE)) as EnvDTE.DTE; + + if (dte != null && dte.Solution != null && !string.IsNullOrWhiteSpace(dte.Solution.FullName)) + { + var solutionDir = Path.GetDirectoryName(dte.Solution.FullName); + + if (!string.IsNullOrWhiteSpace(solutionDir)) + { + var solutionTrustPath = Path.Combine(solutionDir, ".msbuildguard", "trust.json"); + + if (File.Exists(solutionTrustPath)) + { + try + { + File.Delete(solutionTrustPath); + await this.UiFeedbackService.WriteLineAsync($"Deleted solution trust store: {solutionTrustPath}", CancellationToken.None); + + var signaturePath = solutionTrustPath + ".signature"; + + if (File.Exists(signaturePath)) + { + File.Delete(signaturePath); + await this.UiFeedbackService.WriteLineAsync($"Deleted solution trust signature: {signaturePath}", CancellationToken.None); + } + + MessageBox.Show( + "Solution-level trusts successfully removed.", + "MSBuild Guard - Info", + MessageBoxButton.OK, + MessageBoxImage.Information); + + await this.RescanSolutionSecurityReviewAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + await this.UiFeedbackService.WriteLineAsync($"Failed to delete solution trust store: {ex.Message}", CancellationToken.None); + } + } + else + { + MessageBox.Show( + "No solution-level trust file was found.", + "MSBuild Guard - Info", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + } + } + } + + /// + /// Handles execution of the Remove All User Trusts command. + /// + /// Event sender. + /// Event arguments. + private void ExecuteRemoveAllUserTrusts(object? sender, EventArgs e) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + var result = MessageBox.Show( + "Are you sure you want to permanently remove all user-level trusts?", + "MSBuild Guard - Confirm", + MessageBoxButton.YesNo, + MessageBoxImage.Warning); + + if (result == MessageBoxResult.Yes) + { + this.JoinableTaskFactory.Run(async delegate + { + await this.RemoveUserTrustsInternalAsync().ConfigureAwait(false); + }); + } + } + + /// + /// Internal helper to delete user trust files. + /// + /// A task that completes when deletion is finished. + private async Task RemoveUserTrustsInternalAsync() + { + await this.JoinableTaskFactory.SwitchToMainThreadAsync(this.DisposalToken); + + var trustStoreService = new Core.Trust.TrustStoreService(); + var userPath = trustStoreService.GetDefaultUserTrustPath(); + + if (File.Exists(userPath)) + { + try + { + File.Delete(userPath); + await this.UiFeedbackService.WriteLineAsync($"Deleted user trust store: {userPath}", CancellationToken.None); + + var signaturePath = userPath + ".signature"; + + if (File.Exists(signaturePath)) + { + File.Delete(signaturePath); + await this.UiFeedbackService.WriteLineAsync($"Deleted user trust signature: {signaturePath}", CancellationToken.None); + } + + MessageBox.Show( + "User-level trusts successfully removed.", + "MSBuild Guard - Info", + MessageBoxButton.OK, + MessageBoxImage.Information); + + await this.RescanSolutionSecurityReviewAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + await this.UiFeedbackService.WriteLineAsync($"Failed to delete user trust store: {ex.Message}", CancellationToken.None); + } + } + else + { + MessageBox.Show( + "No user-level trust file was found.", + "MSBuild Guard - Info", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + } + + /// + /// Purges all trust files across user profile, active solution, and recent project paths. + /// + /// A task that completes when purging is done. + private async Task PurgeAllTrustsAsync() + { + await this.JoinableTaskFactory.SwitchToMainThreadAsync(this.DisposalToken); + await this.UiFeedbackService.WriteLineAsync("Purging all trust stores due to EnforceAsymmetricSignatures downgrade...", CancellationToken.None); + + var trustStoreService = new Core.Trust.TrustStoreService(); + var userPath = trustStoreService.GetDefaultUserTrustPath(); + + if (File.Exists(userPath)) + { + try + { + File.Delete(userPath); + await this.UiFeedbackService.WriteLineAsync($"Deleted user trust store: {userPath}", CancellationToken.None); + + var signaturePath = userPath + ".signature"; + + if (File.Exists(signaturePath)) + { + File.Delete(signaturePath); + await this.UiFeedbackService.WriteLineAsync($"Deleted user trust signature: {signaturePath}", CancellationToken.None); + } + } + catch (Exception ex) + { + await this.UiFeedbackService.WriteLineAsync($"Failed to delete user trust store: {ex.Message}", CancellationToken.None); + } + } + + var currentSolutionDir = string.Empty; + var dte = await this.GetServiceAsync(typeof(SDTE)) as EnvDTE.DTE; + + if (dte != null && dte.Solution != null && !string.IsNullOrWhiteSpace(dte.Solution.FullName)) + { + currentSolutionDir = Path.GetDirectoryName(dte.Solution.FullName); + } + + var foldersToScan = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(currentSolutionDir)) + { + foldersToScan.Add(currentSolutionDir); + } + + try + { + using (var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(dte?.RegistryRoot?.Replace("_Config", "") + "\\MRUItems\\Solution\\Items")) + { + if (key != null) + { + foreach (var valName in key.GetValueNames()) + { + var val = key.GetValue(valName) as string; + + if (!string.IsNullOrWhiteSpace(val)) + { + var pathPart = val.Split('|')[0]; + + if (File.Exists(pathPart)) + { + var dir = Path.GetDirectoryName(pathPart); + + if (!string.IsNullOrWhiteSpace(dir)) + { + foldersToScan.Add(dir); + } + } + } + } + } + } + } + catch + { + } + + foreach (var folder in foldersToScan) + { + if (Directory.Exists(folder)) + { + try + { + PurgeTrustFilesInDir(folder); + } + catch (Exception ex) + { + await this.UiFeedbackService.WriteLineAsync($"Failed to purge trusts in folder {folder}: {ex.Message}", CancellationToken.None); + } + } + } + + MessageBox.Show( + "All local, solution, and project trust stores have been successfully purged.", + "MSBuild Guard - Info", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + + /// + /// Recursively deletes .msbuildguard/trust.json files within directory. + /// + /// Root directory. + /// Current recursion depth. + private static void PurgeTrustFilesInDir(string dir, int depth = 0) + { + if (depth > 5) + { + return; + } + + try + { + foreach (var subDir in Directory.GetDirectories(dir)) + { + var dirName = Path.GetFileName(subDir); + + if (string.Equals(dirName, ".msbuildguard", StringComparison.OrdinalIgnoreCase)) + { + var trustFile = Path.Combine(subDir, "trust.json"); + + if (File.Exists(trustFile)) + { + try + { + File.Delete(trustFile); + + var signaturePath = trustFile + ".signature"; + + if (File.Exists(signaturePath)) + { + File.Delete(signaturePath); + } + } + catch + { + } + } + } + else if (dirName != "node_modules" && dirName != ".git" && dirName != "bin" && dirName != "obj") + { + PurgeTrustFilesInDir(subDir, depth + 1); + } + } + } + catch + { + } + } } } diff --git a/MSBuildGuard.VisualStudio/Menus.vsct b/MSBuildGuard.VisualStudio/Menus.vsct index 49ba18e..6188ffd 100644 --- a/MSBuildGuard.VisualStudio/Menus.vsct +++ b/MSBuildGuard.VisualStudio/Menus.vsct @@ -33,6 +33,14 @@ + + + + + + + + @@ -63,21 +71,21 @@ + + + + @@ -116,6 +138,8 @@ + + @@ -123,6 +147,8 @@ + + diff --git a/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsPage.cs b/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsPage.cs index 98a1d5d..57d2707 100644 --- a/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsPage.cs +++ b/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsPage.cs @@ -5,6 +5,21 @@ namespace MSBuildGuard.VisualStudio.Options { + /// + /// Represents the key management mode kinds. + /// + public enum KeyManagementModeKind + { + /// Mode is unconfigured. + Unconfigured, + + /// Solo Developer mode using DPAPI. + DPAPI, + + /// Team Environment mode using asymmetric certificates. + Certificates + } + /// /// Represents persisted Visual Studio options placeholder for MSBuild Guard. /// Enables the Tools > Options tree node registration linked to Unified Settings. @@ -28,6 +43,8 @@ public sealed class MSBuildGuardOptionsPage : DialogPage /// private const string DefaultReflectionInteropIndicators = "System.Reflection;Assembly.Load;Activator.CreateInstance;GetType(;dynamic ;DllImport;Marshal.GetDelegateForFunctionPointer;LoadLibrary"; + private bool allowSharingTrustsInRepositories; + /// /// Gets or sets a value indicating whether security review windows should auto-open when a solution or project is opened. /// @@ -55,6 +72,24 @@ public sealed class MSBuildGuardOptionsPage : DialogPage [DefaultValue(true)] public bool ScanNuGetPackages { get; set; } = true; + /// + /// Gets or sets the key management mode used for signing and validating trust files. + /// + [Category("Trust Management")] + [DisplayName("Key Management Mode")] + [Description("Solo Developer (Local DPAPI) uses local machine keys, disabling repository sharing. Team Environment (Certificates) uses public/private certificate keys.")] + [DefaultValue(KeyManagementModeKind.Unconfigured)] + public KeyManagementModeKind KeyManagementMode { get; set; } = KeyManagementModeKind.Unconfigured; + + /// + /// Gets or sets a value indicating whether strict asymmetric certificate-based signature verification is enforced. + /// + [Category("Trust Management")] + [DisplayName("Enforce Asymmetric Signatures")] + [Description("Strictly enforces asymmetric certificate-based signature verification for trust stores and policy documents.")] + [DefaultValue(false)] + public bool EnforceAsymmetricSignatures { get; set; } + /// /// Gets or sets a value indicating whether trust files may be shared in repositories. /// @@ -62,7 +97,24 @@ public sealed class MSBuildGuardOptionsPage : DialogPage [DisplayName("Allow sharing trusts in repositories")] [Description("When enabled, MSBuild Guard removes managed .msbuildguard ignore entries from .gitignore files. When disabled, the entries are enforced.")] [DefaultValue(false)] - public bool AllowSharingTrustsInRepositories { get; set; } + public bool AllowSharingTrustsInRepositories + { + get + { + return allowSharingTrustsInRepositories; + } + set + { + if (KeyManagementMode == KeyManagementModeKind.DPAPI && value) + { + allowSharingTrustsInRepositories = false; + + return; + } + + allowSharingTrustsInRepositories = value; + } + } /// /// Gets or sets a semicolon-separated list of file extensions to scan. diff --git a/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsSnapshot.cs b/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsSnapshot.cs index 9c689f4..9fb0c16 100644 --- a/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsSnapshot.cs +++ b/MSBuildGuard.VisualStudio/Options/MSBuildGuardOptionsSnapshot.cs @@ -25,6 +25,16 @@ internal sealed class MSBuildGuardOptionsSnapshot /// internal bool AllowSharingTrustsInRepositories { get; set; } + /// + /// Gets or sets the key management mode used for signing and validating trust files. + /// + internal KeyManagementModeKind KeyManagementMode { get; set; } = KeyManagementModeKind.Unconfigured; + + /// + /// Gets or sets a value indicating whether strict asymmetric certificate-based signature verification is enforced. + /// + internal bool EnforceAsymmetricSignatures { get; set; } + /// /// Gets or sets a semicolon-separated list of file types to scan. /// diff --git a/MSBuildGuard.VisualStudio/Options/SettingsNames.cs b/MSBuildGuard.VisualStudio/Options/SettingsNames.cs index 922fe00..fadd368 100644 --- a/MSBuildGuard.VisualStudio/Options/SettingsNames.cs +++ b/MSBuildGuard.VisualStudio/Options/SettingsNames.cs @@ -33,6 +33,20 @@ internal static class SettingsNames "extensions.msbuildguard.trustManagement.allowSharingTrustsInRepositories", "MSBuild Guard\\General\\AllowSharingTrustsInRepositories"); + /// + /// Gets the key management mode setting metadata. + /// + internal static SettingName KeyManagementMode { get; } = new( + "extensions.msbuildguard.trustManagement.keyManagementMode", + "MSBuild Guard\\General\\KeyManagementMode"); + + /// + /// Gets the enforce asymmetric signatures setting metadata. + /// + internal static SettingName EnforceAsymmetricSignatures { get; } = new( + "extensions.msbuildguard.trustManagement.enforceAsymmetricSignatures", + "MSBuild Guard\\General\\EnforceAsymmetricSignatures"); + /// /// Gets the file types to scan setting metadata. /// diff --git a/MSBuildGuard.VisualStudio/Options/UnifiedSettingsOptionsProvider.cs b/MSBuildGuard.VisualStudio/Options/UnifiedSettingsOptionsProvider.cs index 78c9ca6..92d04d5 100644 --- a/MSBuildGuard.VisualStudio/Options/UnifiedSettingsOptionsProvider.cs +++ b/MSBuildGuard.VisualStudio/Options/UnifiedSettingsOptionsProvider.cs @@ -76,11 +76,19 @@ private static MSBuildGuardOptionsSnapshot ReadSnapshot(AsyncPackage package) snapshot.EnableBaselineOnboarding = ReadBoolean(store, SettingsNames.EnableBaselineOnboarding, snapshot.EnableBaselineOnboarding); snapshot.ScanNuGetPackages = ReadBoolean(store, SettingsNames.ScanNuGetPackages, snapshot.ScanNuGetPackages); snapshot.AllowSharingTrustsInRepositories = ReadBoolean(store, SettingsNames.AllowSharingTrustsInRepositories, snapshot.AllowSharingTrustsInRepositories); + snapshot.EnforceAsymmetricSignatures = ReadBoolean(store, SettingsNames.EnforceAsymmetricSignatures, snapshot.EnforceAsymmetricSignatures); snapshot.FileTypesToScan = ReadString(store, SettingsNames.FileTypesToScan, snapshot.FileTypesToScan); snapshot.ProcessCreationIndicators = ReadString(store, SettingsNames.ProcessCreationIndicators, snapshot.ProcessCreationIndicators); snapshot.ReflectionInteropIndicators = ReadString(store, SettingsNames.ReflectionInteropIndicators, snapshot.ReflectionInteropIndicators); snapshot.AdditionalBlockedAssemblies = ReadString(store, SettingsNames.AdditionalBlockedAssemblies, snapshot.AdditionalBlockedAssemblies); + var keyModeStr = ReadString(store, SettingsNames.KeyManagementMode, "unconfigured"); + + if (Enum.TryParse(keyModeStr, true, out var keyMode)) + { + snapshot.KeyManagementMode = keyMode; + } + return snapshot; } @@ -90,6 +98,8 @@ private static bool AreEquivalent(MSBuildGuardOptionsSnapshot left, MSBuildGuard left.EnableBaselineOnboarding == right.EnableBaselineOnboarding && left.ScanNuGetPackages == right.ScanNuGetPackages && left.AllowSharingTrustsInRepositories == right.AllowSharingTrustsInRepositories && + left.KeyManagementMode == right.KeyManagementMode && + left.EnforceAsymmetricSignatures == right.EnforceAsymmetricSignatures && string.Equals(left.FileTypesToScan, right.FileTypesToScan, StringComparison.Ordinal) && string.Equals(left.ProcessCreationIndicators, right.ProcessCreationIndicators, StringComparison.Ordinal) && string.Equals(left.ReflectionInteropIndicators, right.ReflectionInteropIndicators, StringComparison.Ordinal) && diff --git a/MSBuildGuard.VisualStudio/PackageIds.cs b/MSBuildGuard.VisualStudio/PackageIds.cs index bdd70de..8f69e27 100644 --- a/MSBuildGuard.VisualStudio/PackageIds.cs +++ b/MSBuildGuard.VisualStudio/PackageIds.cs @@ -50,6 +50,16 @@ internal static class PackageIds /// public const int ManagePackageTrustsCommandId = 0x010A; + /// + /// Remove all solution trusts command id. + /// + public const int RemoveAllSolutionTrustsCommandId = 0x010B; + + /// + /// Remove all user trusts command id. + /// + public const int RemoveAllUserTrustsCommandId = 0x010C; + /// /// Policy editor tool window id. /// diff --git a/MSBuildGuard.VisualStudio/ToolWindows/KeyManagementOnboardingDialog.xaml b/MSBuildGuard.VisualStudio/ToolWindows/KeyManagementOnboardingDialog.xaml new file mode 100644 index 0000000..ef74f06 --- /dev/null +++ b/MSBuildGuard.VisualStudio/ToolWindows/KeyManagementOnboardingDialog.xaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +