-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Warn when a vulnerable or EOL SDK version is resolved #53557
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
JamieMagee
wants to merge
1
commit into
dotnet:main
Choose a base branch
from
JamieMagee:jamieMagee/sdk-vulnerability-warnings
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
432 changes: 432 additions & 0 deletions
432
src/Cli/Microsoft.DotNet.Cli.Definitions/Microsoft.DotNet.Cli.Definitions.csproj.lscache
Large diffs are not rendered by default.
Oops, something went wrong.
673 changes: 673 additions & 0 deletions
673
src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj.lscache
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
199 changes: 199 additions & 0 deletions
199
src/Cli/dotnet/SdkVulnerability/SdkReleaseMetadataCache.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
|
|
||
| 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; | ||
| } | ||
|
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; | ||
| } | ||
|
JamieMagee marked this conversation as resolved.
|
||
|
|
||
| WriteSummary(sdkVersion, info); | ||
| } | ||
|
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
139
src/Cli/dotnet/SdkVulnerability/SdkVulnerabilityChecker.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
| } | ||
|
|
||
|
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(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.