Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -77,47 +77,30 @@ public void Constructor_WithTrustedFindings_CalculatesCorrectRiskScore()

report.FilesScanned.Add(fileRecord);

var userTrustPath = new TrustStoreService().GetDefaultUserTrustPath();
var model = new BuildBlockDialogViewModel(report, this.tempDir);
var userTrustPath = Path.Combine(this.tempDir, "user-trust.json");
var model = new BuildBlockDialogViewModel(report, report.Target.TargetPath, null, userTrustPath);

// Initially, the finding is not trusted.
model.RiskScore.ShouldBe(20);

// Now we write a trust entry for it.
var trustStoreService = new TrustStoreService();
var userTrustStore = trustStoreService.Load(userTrustPath);
var originalDecisions = new List<TrustDecisionEntry>(userTrustStore.Decisions);

try
trustStoreService.AddDecision(userTrustPath, new TrustDecisionEntry
{
trustStoreService.AddDecision(userTrustPath, new TrustDecisionEntry
{
DecisionId = Guid.NewGuid().ToString("N"),
Scope = "Finding",
SubjectHash = "fingerprint-1",
Decision = "Trust",
Reason = "Test trust",
UserSid = "TestSid",
CreatedAtUtc = DateTimeOffset.UtcNow
});

var model2 = new BuildBlockDialogViewModel(report, this.tempDir);

model2.RiskScore.ShouldBe(0);
model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString());
}
finally
{
// Restore the original user trust store to not pollute the host.
userTrustStore.Decisions.Clear();

foreach (var d in originalDecisions)
{
userTrustStore.Decisions.Add(d);
}

trustStoreService.Save(userTrustPath, userTrustStore);
}
DecisionId = Guid.NewGuid().ToString("N"),
Scope = "Finding",
SubjectHash = "fingerprint-1",
Decision = "Trust",
Reason = "Test trust",
UserSid = "TestSid",
CreatedAtUtc = DateTimeOffset.UtcNow
});

var model2 = new BuildBlockDialogViewModel(report, report.Target.TargetPath, null, userTrustPath);

model2.RiskScore.ShouldBe(0);
model2.RecommendedAction.ShouldBe(RecommendedAction.Allow.ToString());
}
}
}
2 changes: 1 addition & 1 deletion MSBuildGuard.VisualStudio/MSBuildGuard.VisualStudio.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<RootNamespace>MSBuildGuard.VisualStudio</RootNamespace>
<Product>MSBuildGuard</Product>
<Title>MSBuildGuard</Title>
<Version>0.3.1</Version>
<Version>0.3.2</Version>
<SignAssembly>False</SignAssembly>
<Authors>Hefaistos68</Authors>
<Company>Hefaistos68.dev</Company>
Expand Down
146 changes: 144 additions & 2 deletions MSBuildGuard.VisualStudio/Services/SolutionMonitorService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ internal sealed class SolutionMonitorService : IDisposable
private bool isStarted;
private string? lastScannedSolutionPath;

/// <summary>
/// Watcher for Git HEAD file changes.
/// </summary>
private FileSystemWatcher? gitWatcher;

/// <summary>
/// Initializes a new instance of the <see cref="SolutionMonitorService"/> class.
/// </summary>
Expand Down Expand Up @@ -57,6 +62,13 @@ public async Task StartAsync(CancellationToken cancellationToken)
SolutionEvents.OnBeforeOpenProject += this.OnBeforeOpenProject;
this.isStarted = true;

var openSolutionPath = SolutionDiscoveryService.GetOpenSolutionPath();

if (!string.IsNullOrWhiteSpace(openSolutionPath))
{
this.StartGitWatcher(openSolutionPath!);
}

await this.package.UiFeedbackService.WriteLineAsync("Solution monitor started.", CancellationToken.None);
_ = this.QueueScanAsync(null, cancellationToken);
}
Expand All @@ -74,6 +86,7 @@ public void Dispose()
this.isStarted = false;
}

this.StopGitWatcher();
this.scanGate.Dispose();
}

Expand All @@ -84,8 +97,20 @@ public void Dispose()
/// <param name="e">Solution open event arguments.</param>
private void OnAfterOpenSolution(object? sender, OpenSolutionEventArgs e)
{
_ = this.package.UiFeedbackService.WriteLineAsync("Solution opened.", CancellationToken.None);
_ = this.QueueScanAsync(null, this.package.DisposalToken);
ThreadHelper.JoinableTaskFactory.RunAsync(async delegate
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(this.package.DisposalToken);

await this.package.UiFeedbackService.WriteLineAsync("Solution opened.", CancellationToken.None);
_ = this.QueueScanAsync(null, this.package.DisposalToken);

var openSolutionPath = SolutionDiscoveryService.GetOpenSolutionPath();

if (!string.IsNullOrWhiteSpace(openSolutionPath))
{
this.StartGitWatcher(openSolutionPath!);
}
}).FileAndForget(nameof(SolutionMonitorService));
}

/// <summary>
Expand All @@ -112,9 +137,126 @@ private void OnAfterCloseSolution(object? sender, EventArgs e)
this.lastScannedSolutionPath = null;
}

this.StopGitWatcher();

_ = this.package.OnSolutionUnloadedAsync();
}

/// <summary>
/// Resolves the actual git directory path, handling submodules and worktrees.
/// </summary>
/// <param name="repositoryRoot">The repository root directory.</param>
/// <returns>The resolved git directory path, or null.</returns>
private static string? GetGitDir(string repositoryRoot)
{
var gitPath = Path.Combine(repositoryRoot, ".git");

if (Directory.Exists(gitPath))
{
return gitPath;
}

if (File.Exists(gitPath))
{
try
{
var content = File.ReadAllText(gitPath).Trim();

if (content.StartsWith("gitdir:", StringComparison.OrdinalIgnoreCase))
{
var relativePath = content.Substring(7).Trim();
var absolutePath = Path.IsPathRooted(relativePath)
? relativePath
: Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));

if (Directory.Exists(absolutePath))
{
return absolutePath;
}
}
}
catch
{
// Ignore
}
}

return null;
}

/// <summary>
/// Starts monitoring Git HEAD changes for the specified solution directory.
/// </summary>
/// <param name="solutionPath">The path to the solution.</param>
private void StartGitWatcher(string solutionPath)
{
this.StopGitWatcher();

var repoRoot = SolutionDiscoveryService.TryResolveRepositoryRoot(solutionPath);

if (string.IsNullOrWhiteSpace(repoRoot))
{
return;
}

var gitDir = GetGitDir(repoRoot!);

if (string.IsNullOrWhiteSpace(gitDir) || !Directory.Exists(gitDir))
{
return;
}

try
{
this.gitWatcher = new FileSystemWatcher(gitDir!, "HEAD")
{
NotifyFilter = NotifyFilters.LastWrite
};

this.gitWatcher.Changed += this.OnGitHeadChanged;
this.gitWatcher.EnableRaisingEvents = true;
}
catch (Exception ex)
{
_ = this.package.UiFeedbackService.WriteLineAsync($"Failed to start Git watcher: {ex.Message}", CancellationToken.None);
}
}

/// <summary>
/// Stops and disposes the Git HEAD watcher.
/// </summary>
private void StopGitWatcher()
{
if (this.gitWatcher != null)
{
this.gitWatcher.EnableRaisingEvents = false;
this.gitWatcher.Changed -= this.OnGitHeadChanged;
this.gitWatcher.Dispose();
this.gitWatcher = null;
}
}

/// <summary>
/// Handles Git HEAD file change events.
/// </summary>
/// <param name="sender">Event sender.</param>
/// <param name="e">File system event arguments.</param>
private void OnGitHeadChanged(object sender, FileSystemEventArgs e)
{
ThreadHelper.JoinableTaskFactory.RunAsync(async delegate
{
try
{
await this.package.UiFeedbackService.WriteLineAsync("Git HEAD changed. Re-applying trust sharing preference.", CancellationToken.None);
await this.package.ApplyTrustSharingPreferenceAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Failed to apply trust sharing preference: {ex.Message}");
}
}).FileAndForget(nameof(SolutionMonitorService));
}

/// <summary>
/// Acquires the scan gate, resolves the target path, runs the scanner, and raises <see cref="ScanCompleted"/>.
/// </summary>
Expand Down
17 changes: 15 additions & 2 deletions MSBuildGuard.VisualStudio/ToolWindows/BuildBlockDialogViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public BuildBlockDialogViewModel(ScanReport report)
/// <param name="report">The scan report.</param>
/// <param name="solutionPath">The solution path.</param>
public BuildBlockDialogViewModel(ScanReport report, string? solutionPath)
: this(report, solutionPath, null)
: this(report, solutionPath, null, null)
{
}

Expand All @@ -124,6 +124,18 @@ public BuildBlockDialogViewModel(ScanReport report, string? solutionPath)
/// <param name="solutionPath">The solution path.</param>
/// <param name="projectPathFilter">The project path filter.</param>
public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string? projectPathFilter)
: this(report, solutionPath, projectPathFilter, null)
{
}

/// <summary>
/// Initializes a new instance of the <see cref="BuildBlockDialogViewModel"/> class with an explicit solution path, optional project path filter, and optional user-level trust store path override.
/// </summary>
/// <param name="report">The scan report.</param>
/// <param name="solutionPath">The solution path.</param>
/// <param name="projectPathFilter">The project path filter.</param>
/// <param name="userTrustPath">Optional user-level trust store path to override the default.</param>
public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string? projectPathFilter, string? userTrustPath)
{
if (report == null)
{
Expand All @@ -140,7 +152,8 @@ public BuildBlockDialogViewModel(ScanReport report, string? solutionPath, string
report.Target.TargetPath.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) ||
report.Target.TargetPath.EndsWith(".proj", StringComparison.OrdinalIgnoreCase)));
var currentProjectPath = !string.IsNullOrWhiteSpace(projectPathFilter) ? projectPathFilter : (isProject ? report.Target.TargetPath : null);
var trustStore = trustStoreService.LoadMergedTrustStore(trustStoreService.GetDefaultUserTrustPath(), solutionPath, currentProjectPath);
var userPath = !string.IsNullOrWhiteSpace(userTrustPath) ? userTrustPath! : trustStoreService.GetDefaultUserTrustPath();
var trustStore = trustStoreService.LoadMergedTrustStore(userPath, solutionPath, currentProjectPath);
Comment thread
Hefaistos68 marked this conversation as resolved.
var signatureCache = new Dictionary<string, AssemblySignatureService>(StringComparer.OrdinalIgnoreCase);
var projectTrustStoreCache = new Dictionary<string, TrustStoreDocument>(StringComparer.OrdinalIgnoreCase);
var activeRiskScore = 0;
Expand Down
2 changes: 1 addition & 1 deletion MSBuildGuard.VisualStudio/source.extension.vsixmanifest
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
<Metadata>
<Identity Id="MSBuildGuard.VisualStudio" Version="0.3.1" Language="en-US" Publisher="Hefaistos68" />
<Identity Id="MSBuildGuard.VisualStudio" Version="0.3.2" Language="en-US" Publisher="Hefaistos68" />
<DisplayName>MSBuild Guard for Visual Studio</DisplayName>
<Description xml:space="preserve">Real-time MSBuild security review, trust workflows, build blocking, and dynamic policy enforcement for Visual Studio.</Description>
<MoreInfo>https://github.com/Hefaistos68/MSBuildGuard</MoreInfo>
Expand Down
Loading