Skip to content
Open
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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/Cli/dotnet/Commands/Restore/RestoringCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.ObjectModel;
using Microsoft.DotNet.Cli.Commands.MSBuild;
using Microsoft.DotNet.Cli.Commands.Workload.Install;
using Microsoft.DotNet.Cli.SdkVulnerability;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Configurer;

Expand Down Expand Up @@ -41,6 +42,7 @@ public RestoringCommand(
{
userProfileDir = CliFolderPathCalculator.DotnetUserProfileFolderPath;
Task.Run(() => WorkloadManifestUpdater.BackgroundUpdateAdvertisingManifestsAsync(userProfileDir));
SdkVulnerabilityNotifier.BackgroundUpdateCacheIfNeeded();
SeparateRestoreCommand = GetSeparateRestoreCommand(msbuildArgs, noRestore, msbuildPath);
AdvertiseWorkloadUpdates = advertiseWorkloadUpdates ?? msbuildArgs.OtherMSBuildArgs.All(arg => FlagsThatTriggerSilentRestore.All(f => !arg.Contains(f, StringComparison.OrdinalIgnoreCase)));

Expand Down
199 changes: 199 additions & 0 deletions src/Cli/dotnet/SdkVulnerability/SdkReleaseMetadataCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Deployment.DotNet.Releases;
using Microsoft.DotNet.Configurer;

namespace Microsoft.DotNet.Cli.SdkVulnerability;

/// <summary>
/// Manages a local cache of .NET release metadata used for SDK vulnerability and EOL checks.
/// The cache lives at ~/.dotnet/sdk-vulnerability-cache/ and is refreshed in the background
/// on a sentinel-based interval (default 24 hours).
/// </summary>
internal sealed class SdkReleaseMetadataCache
{
private const string CacheDirectoryName = "sdk-vulnerability-cache";
private const string SentinelFileName = ".sentinel";
private const string SummaryFilePrefix = "sdk-status-";
private const int DefaultUpdateIntervalHours = 24;

private readonly string _cacheDirectory;
private readonly Func<string, string?> _getEnvironmentVariable;

public SdkReleaseMetadataCache()
: this(
Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, CacheDirectoryName),
Environment.GetEnvironmentVariable)
{
}

internal SdkReleaseMetadataCache(string cacheDirectory, Func<string, string?> getEnvironmentVariable)
{
_cacheDirectory = cacheDirectory;
_getEnvironmentVariable = getEnvironmentVariable;
}

/// <summary>
/// Returns true if the vulnerability check is disabled via environment variable.
/// </summary>
public bool IsDisabled()
{
return bool.TryParse(_getEnvironmentVariable(EnvironmentVariableNames.SDK_VULNERABILITY_CHECK_DISABLE), out bool disabled) && disabled;
}

/// <summary>
/// Returns true if the sentinel file indicates the cache is due for a refresh.
/// </summary>
public bool IsDueForUpdate()
{
var sentinelPath = Path.Combine(_cacheDirectory, SentinelFileName);

if (!int.TryParse(_getEnvironmentVariable(EnvironmentVariableNames.SDK_VULNERABILITY_CHECK_INTERVAL_HOURS), out int updateIntervalHours)
|| updateIntervalHours < 1)
{
updateIntervalHours = DefaultUpdateIntervalHours;
}

if (File.Exists(sentinelPath))
{
var lastWriteTime = File.GetLastWriteTimeUtc(sentinelPath);
if (lastWriteTime.AddHours(updateIntervalHours) > DateTime.UtcNow)
{
return false;
}
}
Comment thread
JamieMagee marked this conversation as resolved.

return true;
}

/// <summary>
/// Reads a previously cached vulnerability summary for the given SDK version.
/// Returns null if no cached data is available.
/// </summary>
public SdkVulnerabilityInfo? ReadCachedSummary(string sdkVersion)
{
if (sdkVersion.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
return null;
}

var summaryPath = GetSummaryPath(sdkVersion);
if (!File.Exists(summaryPath))
{
return null;
}
Comment thread
JamieMagee marked this conversation as resolved.

try
{
var json = File.ReadAllText(summaryPath);
return JsonSerializer.Deserialize(json, SdkVulnerabilityJsonContext.Default.SdkVulnerabilityInfo);
}
catch
{
return null;
}
}

/// <summary>
/// Fetches release metadata, computes vulnerability status for the given SDK version,
/// writes the result to cache, and updates the sentinel. This is intended to be called
/// from a background task.
/// </summary>
public async Task UpdateCacheAsync(string sdkVersion, CancellationToken cancellationToken = default)
{
ProductCollection productCollection = await ProductCollection.GetAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();

if (!ReleaseVersion.TryParse(sdkVersion, out ReleaseVersion? parsedVersion))
{
TouchSentinel();
return;
}

string channelVersion = $"{parsedVersion.Major}.{parsedVersion.Minor}";
Product? product = productCollection
.FirstOrDefault(p => p.ProductVersion.Equals(channelVersion));

if (product is null)
{
TouchSentinel();
return;
}

IEnumerable<ProductRelease> releases;
try
{
releases = await product.GetReleasesAsync().ConfigureAwait(false);
}
catch
{
releases = [];
}
cancellationToken.ThrowIfCancellationRequested();

SdkVulnerabilityInfo? info = SdkVulnerabilityChecker.Check(sdkVersion, productCollection, _ => releases);
if (info is null)
{
TouchSentinel();
return;
}
Comment thread
JamieMagee marked this conversation as resolved.

WriteSummary(sdkVersion, info);
}
Comment thread
JamieMagee marked this conversation as resolved.

private void WriteSummary(string sdkVersion, SdkVulnerabilityInfo info)
{
try
{
Directory.CreateDirectory(_cacheDirectory);
var summaryPath = GetSummaryPath(sdkVersion);
var json = JsonSerializer.Serialize(info, SdkVulnerabilityJsonContext.Default.SdkVulnerabilityInfo);

// Atomic write: write to temp file then move, so concurrent readers
// (e.g., the MSBuild task) never see partially-written JSON.
var tempPath = Path.Combine(_cacheDirectory, Path.GetRandomFileName());
File.WriteAllText(tempPath, json);
File.Move(tempPath, summaryPath, overwrite: true);

// Only mark the cache as fresh after a successful write.
TouchSentinel();
}
catch
{
// Silently fail — cache write failure should not break the CLI
}
}

private void TouchSentinel()
{
try
{
var sentinelPath = Path.Combine(_cacheDirectory, SentinelFileName);
Directory.CreateDirectory(_cacheDirectory);
if (File.Exists(sentinelPath))
{
File.SetLastWriteTimeUtc(sentinelPath, DateTime.UtcNow);
}
else
{
File.Create(sentinelPath).Close();
}
}
catch
{
// Silently fail
}
}

private string GetSummaryPath(string sdkVersion) =>
Path.Combine(_cacheDirectory, $"{SummaryFilePrefix}{sdkVersion}.json");
}

[JsonSerializable(typeof(SdkVulnerabilityInfo))]
[JsonSerializable(typeof(SdkCveInfo))]
internal partial class SdkVulnerabilityJsonContext : JsonSerializerContext
{
}
139 changes: 139 additions & 0 deletions src/Cli/dotnet/SdkVulnerability/SdkVulnerabilityChecker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Deployment.DotNet.Releases;

namespace Microsoft.DotNet.Cli.SdkVulnerability;

/// <summary>
/// Checks an SDK version against release metadata for vulnerabilities and EOL status.
/// Pure logic — no I/O or network calls.
/// </summary>
internal static class SdkVulnerabilityChecker
{
/// <summary>
/// Checks the given SDK version against the product collection and release data.
/// </summary>
/// <param name="sdkVersionString">The resolved SDK version string (e.g. "9.0.300").</param>
/// <param name="productCollection">The product collection from releases-index.json.</param>
/// <param name="getProductReleases">A function to get releases for a product (may read from cache).</param>
/// <returns>Vulnerability info, or null if the SDK version cannot be matched to release data.</returns>
public static SdkVulnerabilityInfo? Check(
string sdkVersionString,
ProductCollection productCollection,
Func<Product, IEnumerable<ProductRelease>> getProductReleases)
{
if (!ReleaseVersion.TryParse(sdkVersionString, out ReleaseVersion? sdkVersion))
{
return null;
}

// Find the product channel for this SDK version (e.g. "9.0" for SDK "9.0.300")
string channelVersion = $"{sdkVersion.Major}.{sdkVersion.Minor}";
Product? product = productCollection
.FirstOrDefault(p => p.ProductVersion.Equals(channelVersion));

if (product is null)
{
return null;
}

bool isEol = product.IsOutOfSupport();
DateTime? eolDate = product.EndOfLifeDate;

// Get all releases for this channel to find CVEs
IEnumerable<ProductRelease> releases = getProductReleases(product);
List<ProductRelease> releaseList = releases.ToList();

if (releaseList.Count == 0)
{
// No release data available — can still report EOL status
return new SdkVulnerabilityInfo
{
IsEol = isEol,
EolDate = eolDate,
};
}

// Find which release contains this SDK version
int currentReleaseIndex = -1;
for (int i = 0; i < releaseList.Count; i++)
{
if (releaseList[i].Sdks.Any(sdk => sdk.Version == sdkVersion))
{
currentReleaseIndex = i;
break;
}
}

Comment thread
JamieMagee marked this conversation as resolved.
// If the SDK version isn't present in any release, we can't determine
// its vulnerability status — return null per the documented contract.
if (currentReleaseIndex < 0)
{
return null;
}

// Collect CVEs from all releases newer than the current one.
// Releases are ordered newest-first from the API.
// All CVEs in releases after our SDK's release are vulnerabilities that affect us.
var cves = new List<SdkCveInfo>();
if (currentReleaseIndex > 0)
{
for (int i = 0; i < currentReleaseIndex; i++)
{
foreach (var cve in releaseList[i].Cves)
{
cves.Add(new SdkCveInfo
{
Id = cve.Id,
Url = cve.DescriptionLink?.ToString() ?? $"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve.Id}"
});
}
}
}

// Deduplicate CVEs by ID (same CVE can appear in multiple releases)
cves = cves.DistinctBy(c => c.Id).ToList();

// Find the latest SDK version in the same feature band
string? latestInBand = FindLatestSdkInFeatureBand(sdkVersion, product, releaseList);

return new SdkVulnerabilityInfo
{
IsEol = isEol,
EolDate = eolDate,
Cves = cves,
LatestSdkVersion = latestInBand,
};
}

private static string? FindLatestSdkInFeatureBand(
ReleaseVersion sdkVersion,
Product product,
List<ProductRelease> releases)
{
// If the product's latest SDK is in the same feature band, use it directly
if (product.LatestSdkVersion?.SdkFeatureBand == sdkVersion.SdkFeatureBand
&& product.LatestSdkVersion > sdkVersion)
{
return product.LatestSdkVersion.ToString();
}

// Otherwise, search through releases for the newest SDK in our feature band
ReleaseVersion? latest = null;
foreach (var release in releases)
{
foreach (var sdk in release.Sdks)
{
if (sdk.Version.SdkFeatureBand == sdkVersion.SdkFeatureBand
&& sdk.Version > sdkVersion
&& (latest is null || sdk.Version > latest))
{
latest = sdk.Version;
}
}
}

return latest?.ToString();
}
}
25 changes: 25 additions & 0 deletions src/Cli/dotnet/SdkVulnerability/SdkVulnerabilityInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.DotNet.Cli.SdkVulnerability;

/// <summary>
/// Contains vulnerability and end-of-life status for a resolved SDK version.
/// </summary>
internal sealed class SdkVulnerabilityInfo
{
public bool IsEol { get; init; }
public DateTime? EolDate { get; init; }
public IReadOnlyList<SdkCveInfo> Cves { get; init; } = [];
public string? LatestSdkVersion { get; init; }
public bool HasVulnerabilities => Cves.Count > 0;
}

/// <summary>
/// Represents a single CVE that affects an SDK version.
/// </summary>
internal sealed class SdkCveInfo
{
public required string Id { get; init; }
public required string Url { get; init; }
}
Loading
Loading