diff --git a/src/CodeMedic/Engines/NuGetInspector.cs b/src/CodeMedic/Engines/NuGetInspector.cs index a706b03..f196f88 100644 --- a/src/CodeMedic/Engines/NuGetInspector.cs +++ b/src/CodeMedic/Engines/NuGetInspector.cs @@ -405,4 +405,469 @@ private List ExtractFromAssetsFile(string assetsFilePath, return null; } + + /// + /// Gets the NuGet global packages folder path by executing 'dotnet nuget locals global-packages --list'. + /// + public async Task GetNuGetGlobalPackagesFolderAsync() + { + try + { + var processInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = "nuget locals global-packages --list", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(processInfo); + if (process != null) + { + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + { + // Parse output like "global-packages: C:\Users\user\.nuget\packages\" + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + foreach (var line in lines) + { + var trimmedLine = line.Trim(); + if (trimmedLine.StartsWith("global-packages:", StringComparison.OrdinalIgnoreCase)) + { + var path = trimmedLine.Substring("global-packages:".Length).Trim(); + if (Directory.Exists(path)) + { + return path; + } + } + } + } + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not determine NuGet global packages folder: {ex.Message}"); + } + + // Fallback to default location + var defaultPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); + return Directory.Exists(defaultPath) ? defaultPath : null; + } + + /// + /// Fetches the latest version for a specific package using the NuGet API. + /// + public async Task FetchLatestVersionAsync(string packageName, string currentVersion) + { + try + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{packageName.ToLowerInvariant()}/index.json"; + var response = await httpClient.GetStringAsync(apiUrl); + + using var doc = JsonDocument.Parse(response); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!doc.RootElement.TryGetProperty("versions", out var versionsElement) || + versionsElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + var versions = new List(); + foreach (var element in versionsElement.EnumerateArray()) + { + if (element.ValueKind == JsonValueKind.String) + { + var value = element.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + versions.Add(value); + } + } + } + + if (versions.Count > 0) + { + var latestStable = versions.Where(v => !IsPreReleaseVersion(v)).LastOrDefault(); + return latestStable ?? versions.Last(); + } + } + catch (HttpRequestException ex) + { + if (ex.Message.Contains("404")) + { + Console.Error.WriteLine($"Debug: Package {packageName} not found on nuget.org"); + } + } + catch (TaskCanceledException) + { + // Timeout - skip silently + } + catch (JsonException ex) + { + Console.Error.WriteLine($"Warning: Failed to parse version data for {packageName}: {ex.Message}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not fetch latest version for {packageName}: {ex.Message}"); + } + + return null; + } + + /// + /// Fetches the published date of the latest version (including prerelease) for a package from NuGet.org. + /// Returns null if the package is not found or if the date cannot be determined. + /// + public async Task FetchLatestVersionPublishedDateAsync(string packageName) + { + try + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + // Use the NuGet V3 registration API to get package metadata + var apiUrl = $"https://api.nuget.org/v3/registration5-semver1/{packageName.ToLowerInvariant()}/index.json"; + var response = await httpClient.GetStringAsync(apiUrl); + + using var doc = JsonDocument.Parse(response); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + // The root-level commitTimeStamp represents the most recent catalog update, + // which corresponds to the publish date of the latest version + if (doc.RootElement.TryGetProperty("commitTimeStamp", out var commitTimeStamp) && + commitTimeStamp.ValueKind == JsonValueKind.String) + { + var timestampStr = commitTimeStamp.GetString(); + if (!string.IsNullOrWhiteSpace(timestampStr) && + DateTime.TryParse(timestampStr, out var publishedDate)) + { + return publishedDate; + } + } + + return null; + } + catch (HttpRequestException ex) + { + if (ex.Message.Contains("404")) + { + Console.Error.WriteLine($"Debug: Package {packageName} not found on nuget.org"); + } + else + { + Console.Error.WriteLine($"Warning: HTTP error fetching publish date for {packageName}: {ex.Message}"); + } + } + catch (TaskCanceledException) + { + // Timeout - skip silently + } + catch (JsonException ex) + { + Console.Error.WriteLine($"Warning: Failed to parse publish date data for {packageName}: {ex.Message}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not fetch publish date for {packageName}: {ex.Message}"); + } + + return null; + } + + /// + /// Determines if a version string represents a pre-release version. + /// + public static bool IsPreReleaseVersion(string version) + { + return version.Contains('-') || version.Contains('+'); + } + + /// + /// Fetches license information from a local .nuspec file in the NuGet global packages cache. + /// Returns a tuple of (License, LicenseUrl). + /// + public async Task<(string? License, string? LicenseUrl)> FetchLicenseFromLocalCacheAsync(string packageName, string version) + { + var globalPackagesPath = await GetNuGetGlobalPackagesFolderAsync(); + if (string.IsNullOrEmpty(globalPackagesPath)) + { + return (null, null); + } + + try + { + var packageFolder = Path.Combine(globalPackagesPath, packageName.ToLowerInvariant(), version.ToLowerInvariant()); + var nuspecPath = Path.Combine(packageFolder, $"{packageName.ToLowerInvariant()}.nuspec"); + + if (!File.Exists(nuspecPath)) + { + nuspecPath = Path.Combine(packageFolder, $"{packageName}.nuspec"); + if (!File.Exists(nuspecPath)) + { + return (null, null); + } + } + + var nuspecContent = await File.ReadAllTextAsync(nuspecPath); + var doc = XDocument.Parse(nuspecContent); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + + var metadata = doc.Root?.Element(ns + "metadata"); + if (metadata == null) + { + return (null, null); + } + + // Check for license element first (newer format) + var licenseElement = metadata.Element(ns + "license"); + if (licenseElement != null) + { + var licenseType = licenseElement.Attribute("type")?.Value; + if (licenseType == "expression") + { + return (licenseElement.Value?.Trim(), null); + } + else if (licenseType == "file") + { + return ("See package contents", null); + } + } + + // Fall back to licenseUrl (older format) + var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(licenseUrl)) + { + var license = ExtractLicenseFromUrl(licenseUrl); + return (license, licenseUrl); + } + + return (null, null); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Error reading license for {packageName}: {ex.Message}"); + return (null, null); + } + } + + /// + /// Fetches license information from the NuGet API for a specific version. + /// Returns a tuple of (License, LicenseUrl). + /// + public async Task<(string? License, string? LicenseUrl)> FetchLicenseFromApiAsync(string packageName, string version) + { + try + { + using var httpClient = new HttpClient(); + httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); + httpClient.Timeout = TimeSpan.FromSeconds(15); + + var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{packageName.ToLowerInvariant()}/{version.ToLowerInvariant()}/{packageName.ToLowerInvariant()}.nuspec"; + var response = await httpClient.GetStringAsync(apiUrl); + + var doc = XDocument.Parse(response); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + + var metadata = doc.Root?.Element(ns + "metadata"); + if (metadata == null) + { + return (null, null); + } + + // Check for license element first (newer format) + var licenseElement = metadata.Element(ns + "license"); + if (licenseElement != null) + { + var licenseType = licenseElement.Attribute("type")?.Value; + if (licenseType == "expression") + { + return (licenseElement.Value?.Trim(), null); + } + else if (licenseType == "file") + { + return ("See package contents", null); + } + } + + // Fall back to licenseUrl (older format) + var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); + if (!string.IsNullOrWhiteSpace(licenseUrl)) + { + var license = ExtractLicenseFromUrl(licenseUrl); + return (license, licenseUrl); + } + + return (null, null); + } + catch (HttpRequestException ex) + { + if (ex.Message.Contains("404")) + { + Console.Error.WriteLine($"Debug: Nuspec for {packageName} version {version} not found on nuget.org"); + } + } + catch (TaskCanceledException) + { + // Timeout - skip silently + } + catch (Exception ex) + { + Console.Error.WriteLine($"Warning: Could not fetch license for {packageName}: {ex.Message}"); + } + + return (null, null); + } + + /// + /// Extracts a license identifier from a license URL using common patterns. + /// + private static string ExtractLicenseFromUrl(string licenseUrl) + { + if (licenseUrl.Contains("mit", StringComparison.OrdinalIgnoreCase)) + { + return "MIT"; + } + else if (licenseUrl.Contains("apache", StringComparison.OrdinalIgnoreCase)) + { + return "Apache-2.0"; + } + else if (licenseUrl.Contains("bsd", StringComparison.OrdinalIgnoreCase)) + { + return "BSD"; + } + else if (licenseUrl.Contains("gpl", StringComparison.OrdinalIgnoreCase)) + { + return "GPL"; + } + else + { + return "See URL"; + } + } + + /// + /// Analyzes package metadata to determine source type and commercial status. + /// Returns a tuple of (SourceType, Commercial). + /// + public static (string SourceType, string Commercial) DetermineSourceTypeAndCommercialStatus( + string packageName, + string? license, + string? licenseUrl, + string? projectUrl, + string? repositoryUrl, + string? authors, + string? owners) + { + var packageId = packageName.ToLowerInvariant(); + var lowerLicense = license?.ToLowerInvariant(); + var lowerLicenseUrl = licenseUrl?.ToLowerInvariant(); + var lowerProjectUrl = projectUrl?.ToLowerInvariant(); + var lowerRepositoryUrl = repositoryUrl?.ToLowerInvariant(); + var lowerAuthors = authors?.ToLowerInvariant(); + var lowerOwners = owners?.ToLowerInvariant(); + + // Determine if it's open source + var isOpenSource = false; + + var openSourceLicenses = new[] { + "mit", "apache", "bsd", "gpl", "lgpl", "mpl", "isc", "unlicense", + "cc0", "zlib", "ms-pl", "ms-rl", "eclipse", "cddl", "artistic" + }; + + if (!string.IsNullOrEmpty(lowerLicense)) + { + isOpenSource = openSourceLicenses.Any(oss => lowerLicense.Contains(oss)); + } + + if (!isOpenSource && !string.IsNullOrEmpty(lowerLicenseUrl)) + { + isOpenSource = openSourceLicenses.Any(oss => lowerLicenseUrl.Contains(oss)) || + lowerLicenseUrl.Contains("github.com") || + lowerLicenseUrl.Contains("opensource.org"); + } + + // Check repository URLs + if (!isOpenSource) + { + var urls = new[] { lowerProjectUrl, lowerRepositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); + isOpenSource = urls.Any(url => + url!.Contains("github.com") || + url.Contains("gitlab.com") || + url.Contains("bitbucket.org") || + url.Contains("codeplex.com") || + url.Contains("sourceforge.net")); + } + + // Determine commercial status + var isMicrosoft = packageId.StartsWith("microsoft.") || + packageId.StartsWith("system.") || + !string.IsNullOrEmpty(lowerAuthors) && lowerAuthors.Contains("microsoft") || + !string.IsNullOrEmpty(lowerOwners) && lowerOwners.Contains("microsoft"); + + var commercialIndicators = new[] { + "commercial", "proprietary", "enterprise", "professional", "premium", + "telerik", "devexpress", "syncfusion", "infragistics", "componentone" + }; + + var hasCommercialIndicators = commercialIndicators.Any(indicator => + (!string.IsNullOrEmpty(lowerLicense) && lowerLicense.Contains(indicator)) || + (!string.IsNullOrEmpty(lowerAuthors) && lowerAuthors.Contains(indicator)) || + (packageId.Contains(indicator))); + + var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; + var hasCommercialLicense = !string.IsNullOrEmpty(lowerLicense) && + commercialLicenses.Any(cl => lowerLicense.Contains(cl)); + + // Determine source type + string sourceType; + if (isOpenSource) + { + sourceType = "Open Source"; + } + else if (hasCommercialLicense || hasCommercialIndicators) + { + sourceType = "Closed Source"; + } + else if (isMicrosoft) + { + sourceType = "Closed Source"; + } + else + { + sourceType = "Unknown"; + } + + // Determine commercial status + string commercial; + if (hasCommercialLicense || hasCommercialIndicators) + { + commercial = "Yes"; + } + else if (isOpenSource || isMicrosoft) + { + commercial = "No"; + } + else + { + commercial = "Unknown"; + } + + return (sourceType, commercial); + } } diff --git a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs index e3bfd1a..8dbb95d 100644 --- a/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs +++ b/src/CodeMedic/Plugins/BomAnalysis/BomAnalysisPlugin.cs @@ -459,19 +459,51 @@ private void AddExternalServicesSection(ReportDocument report) /// private async Task FetchLicenseInformationAsync(IEnumerable packages) { - // Get the NuGet global packages folder - var globalPackagesPath = await GetNuGetGlobalPackagesFolderAsync(); - if (string.IsNullOrEmpty(globalPackagesPath)) - { - Console.Error.WriteLine("Warning: Could not determine NuGet global packages folder location."); - return; - } - var tasks = packages.Select(async package => { try { - await FetchLicenseForPackageAsync(globalPackagesPath, package); + var (license, licenseUrl) = await _inspector!.FetchLicenseFromLocalCacheAsync(package.Name, package.Version); + package.License = license; + package.LicenseUrl = licenseUrl; + + if (!string.IsNullOrEmpty(license)) + { + // Get additional metadata from local nuspec to determine source type and commercial status + var globalPackagesPath = await _inspector.GetNuGetGlobalPackagesFolderAsync(); + if (!string.IsNullOrEmpty(globalPackagesPath)) + { + var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); + var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); + + if (!File.Exists(nuspecPath)) + { + nuspecPath = Path.Combine(packageFolder, $"{package.Name}.nuspec"); + } + + if (File.Exists(nuspecPath)) + { + var nuspecContent = await File.ReadAllTextAsync(nuspecPath); + var doc = XDocument.Parse(nuspecContent); + var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; + var metadata = doc.Root?.Element(ns + "metadata"); + + if (metadata != null) + { + var projectUrl = metadata.Element(ns + "projectUrl")?.Value; + var repositoryUrl = metadata.Element(ns + "repository")?.Attribute("url")?.Value; + var authors = metadata.Element(ns + "authors")?.Value; + var owners = metadata.Element(ns + "owners")?.Value; + + var (sourceType, commercial) = NuGetInspector.DetermineSourceTypeAndCommercialStatus( + package.Name, license, licenseUrl, projectUrl, repositoryUrl, authors, owners); + + package.SourceType = sourceType; + package.Commercial = commercial; + } + } + } + } } catch (Exception ex) { @@ -484,7 +516,7 @@ private async Task FetchLicenseInformationAsync(IEnumerable package } /// - /// Fetches latest version information for packages using 'dotnet nuget search'. + /// Fetches latest version information for packages using the NuGet API. /// private async Task FetchLatestVersionInformationAsync(IEnumerable packages) { @@ -497,7 +529,11 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p await semaphore.WaitAsync(); try { - await FetchLatestVersionForPackageAsync(package); + var latestVersion = await _inspector!.FetchLatestVersionAsync(package.Name, package.Version); + if (!string.IsNullOrEmpty(latestVersion)) + { + package.LatestVersion = latestVersion; + } } catch (Exception ex) { @@ -513,102 +549,6 @@ private async Task FetchLatestVersionInformationAsync(IEnumerable p await Task.WhenAll(tasks); } - /// - /// Fetches the latest version for a specific package using the NuGet API. - /// - private async Task FetchLatestVersionForPackageAsync(PackageInfo package) - { - try - { - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); - httpClient.Timeout = TimeSpan.FromSeconds(10); // Set reasonable timeout - - // Use the NuGet V3 API to get package information - var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/index.json"; - - var response = await httpClient.GetStringAsync(apiUrl); - - using var doc = JsonDocument.Parse(response); - if (doc.RootElement.ValueKind != JsonValueKind.Object) - { - return; - } - - if (!doc.RootElement.TryGetProperty("versions", out var versionsElement) || - versionsElement.ValueKind != JsonValueKind.Array) - { - return; - } - - var versions = new List(); - foreach (var element in versionsElement.EnumerateArray()) - { - if (element.ValueKind == JsonValueKind.String) - { - var value = element.GetString(); - if (!string.IsNullOrWhiteSpace(value)) - { - versions.Add(value); - } - } - } - - if (versions.Count > 0) - { - var latestStable = versions.Where(v => !IsPreReleaseVersion(v)).LastOrDefault(); - package.LatestVersion = latestStable ?? versions.Last(); - } - } - catch (HttpRequestException ex) - { - // Package might not exist on nuget.org or network issue - // Only log 404s for debugging, skip others as they're common for private packages - if (ex.Message.Contains("404")) - { - Console.Error.WriteLine($"Debug: Package {package.Name} not found on nuget.org"); - } - } - catch (TaskCanceledException) - { - // Timeout - skip silently - } - catch (JsonException ex) - { - Console.Error.WriteLine($"Warning: Failed to parse version data for {package.Name}: {ex.Message}"); - } - catch (Exception ex) - { - // Log other unexpected errors but don't fail - this is supplementary information - Console.Error.WriteLine($"Warning: Could not fetch latest version for {package.Name}: {ex.Message}"); - } - } - - /// - /// Determines if a version string represents a pre-release version. - /// - private static bool IsPreReleaseVersion(string version) - { - return version.Contains('-') || version.Contains('+'); - } - - /// - /// Response model for NuGet V3 API version query. - /// - private class NuGetVersionResponse - { - public string[]? Versions { get; set; } - } - - /// - /// Response model for NuGet V3 API package metadata query. - /// - private class NuGetPackageResponse - { - public string? LicenseExpression { get; set; } - public string? LicenseUrl { get; set; } - } - /// /// Fetches latest license information for packages using NuGet API to detect license changes. /// @@ -623,7 +563,13 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p await semaphore.WaitAsync(); try { - await FetchLatestLicenseForPackageAsync(package); + // Skip if we don't have a latest version to check + if (!string.IsNullOrEmpty(package.LatestVersion)) + { + var (license, licenseUrl) = await _inspector!.FetchLicenseFromApiAsync(package.Name, package.LatestVersion); + package.LatestLicense = license; + package.LatestLicenseUrl = licenseUrl; + } } catch (Exception ex) { @@ -639,353 +585,6 @@ private async Task FetchLatestLicenseInformationAsync(IEnumerable p await Task.WhenAll(tasks); } - /// - /// Fetches the latest license for a specific package using the NuGet V3 API. - /// - private async Task FetchLatestLicenseForPackageAsync(PackageInfo package) - { - // Skip if we don't have a latest version to check - if (string.IsNullOrEmpty(package.LatestVersion)) - return; - - try - { - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Add("User-Agent", "CodeMedic/1.0"); - httpClient.Timeout = TimeSpan.FromSeconds(15); // Slightly longer timeout for metadata - - // Use the NuGet V3 API to get package metadata for the latest version - var apiUrl = $"https://api.nuget.org/v3-flatcontainer/{package.Name.ToLowerInvariant()}/{package.LatestVersion.ToLowerInvariant()}/{package.Name.ToLowerInvariant()}.nuspec"; - - var response = await httpClient.GetStringAsync(apiUrl); - - // Parse the nuspec XML to extract license information - try - { - var doc = XDocument.Parse(response); - var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - - var metadata = doc.Root?.Element(ns + "metadata"); - if (metadata != null) - { - // Check for license element first (newer format) - var licenseElement = metadata.Element(ns + "license"); - if (licenseElement != null) - { - var licenseType = licenseElement.Attribute("type")?.Value; - if (licenseType == "expression") - { - package.LatestLicense = licenseElement.Value?.Trim(); - } - else if (licenseType == "file") - { - package.LatestLicense = "See package contents"; - } - } - else - { - // Fall back to licenseUrl (older format) - var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); - if (!string.IsNullOrWhiteSpace(licenseUrl)) - { - package.LatestLicenseUrl = licenseUrl; - // Extract license type from URL patterns (same logic as local license detection) - if (licenseUrl.Contains("mit", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "MIT"; - } - else if (licenseUrl.Contains("apache", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "Apache-2.0"; - } - else if (licenseUrl.Contains("bsd", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "BSD"; - } - else if (licenseUrl.Contains("gpl", StringComparison.OrdinalIgnoreCase)) - { - package.LatestLicense = "GPL"; - } - else - { - package.LatestLicense = "See URL"; - } - } - } - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Could not parse latest nuspec for {package.Name}: {ex.Message}"); - } - } - catch (HttpRequestException ex) - { - // Package might not exist on nuget.org or network issue - if (ex.Message.Contains("404")) - { - Console.Error.WriteLine($"Debug: Latest version nuspec for {package.Name} not found on nuget.org"); - } - } - catch (TaskCanceledException) - { - // Timeout - skip silently - } - catch (Exception ex) - { - // Log other unexpected errors but don't fail - Console.Error.WriteLine($"Warning: Could not fetch latest license for {package.Name}: {ex.Message}"); - } - } - - /// - /// Fetches license information for a specific package from its local .nuspec file. - /// - private async Task FetchLicenseForPackageAsync(string globalPackagesPath, PackageInfo package) - { - try - { - // Construct path to the local .nuspec file - // NuGet packages are stored in: {globalPackages}/{packageId}/{version}/{packageId}.nuspec - var packageFolder = Path.Combine(globalPackagesPath, package.Name.ToLowerInvariant(), package.Version.ToLowerInvariant()); - var nuspecPath = Path.Combine(packageFolder, $"{package.Name.ToLowerInvariant()}.nuspec"); - - if (!File.Exists(nuspecPath)) - { - // Try alternative naming (some packages might use original casing) - nuspecPath = Path.Combine(packageFolder, $"{package.Name}.nuspec"); - if (!File.Exists(nuspecPath)) - { - return; // Skip if we can't find the nuspec file - } - } - - var nuspecContent = await File.ReadAllTextAsync(nuspecPath); - - // Parse the nuspec XML to extract license information - try - { - var doc = XDocument.Parse(nuspecContent); - var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None; - - // Try to get license information from metadata - var metadata = doc.Root?.Element(ns + "metadata"); - if (metadata != null) - { - // Check for license element first (newer format) - var licenseElement = metadata.Element(ns + "license"); - if (licenseElement != null) - { - var licenseType = licenseElement.Attribute("type")?.Value; - if (licenseType == "expression") - { - package.License = licenseElement.Value?.Trim(); - } - else if (licenseType == "file") - { - package.License = "See package contents"; - } - } - else - { - // Fall back to licenseUrl (older format) - var licenseUrl = metadata.Element(ns + "licenseUrl")?.Value?.Trim(); - if (!string.IsNullOrWhiteSpace(licenseUrl)) - { - package.LicenseUrl = licenseUrl; - // Try to extract license type from common URL patterns - if (licenseUrl.Contains("mit", StringComparison.OrdinalIgnoreCase)) - { - package.License = "MIT"; - } - else if (licenseUrl.Contains("apache", StringComparison.OrdinalIgnoreCase)) - { - package.License = "Apache-2.0"; - } - else if (licenseUrl.Contains("bsd", StringComparison.OrdinalIgnoreCase)) - { - package.License = "BSD"; - } - else if (licenseUrl.Contains("gpl", StringComparison.OrdinalIgnoreCase)) - { - package.License = "GPL"; - } - else - { - package.License = "See URL"; - } - } - } - - // Determine source type and commercial status based on license and other metadata - DetermineSourceTypeAndCommercialStatus(package, metadata, ns); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Could not parse nuspec for {package.Name}: {ex.Message}"); - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Error reading license for {package.Name}: {ex.Message}"); - } - } - - /// - /// Gets the NuGet global packages folder path by executing 'dotnet nuget locals global-packages --list'. - /// - private async Task GetNuGetGlobalPackagesFolderAsync() - { - try - { - var processInfo = new ProcessStartInfo - { - FileName = "dotnet", - Arguments = "nuget locals global-packages --list", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo); - if (process != null) - { - var output = await process.StandardOutput.ReadToEndAsync(); - await process.WaitForExitAsync(); - - if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) - { - // Parse output like "global-packages: C:\Users\user\.nuget\packages\" - var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); - foreach (var line in lines) - { - var trimmedLine = line.Trim(); - if (trimmedLine.StartsWith("global-packages:", StringComparison.OrdinalIgnoreCase)) - { - var path = trimmedLine.Substring("global-packages:".Length).Trim(); - if (Directory.Exists(path)) - { - return path; - } - } - } - } - } - } - catch (Exception ex) - { - Console.Error.WriteLine($"Warning: Could not determine NuGet global packages folder: {ex.Message}"); - } - - // Fallback to default location - var defaultPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); - return Directory.Exists(defaultPath) ? defaultPath : null; - } - - /// - /// Determines the source type (Open Source/Closed Source) and commercial status of a package. - /// - private static void DetermineSourceTypeAndCommercialStatus(PackageInfo package, XElement metadata, XNamespace ns) - { - var license = package.License?.ToLowerInvariant(); - var licenseUrl = package.LicenseUrl?.ToLowerInvariant(); - var projectUrl = metadata.Element(ns + "projectUrl")?.Value?.ToLowerInvariant(); - var repositoryUrl = metadata.Element(ns + "repository")?.Attribute("url")?.Value?.ToLowerInvariant(); - var packageId = package.Name.ToLowerInvariant(); - var authors = metadata.Element(ns + "authors")?.Value?.ToLowerInvariant(); - var owners = metadata.Element(ns + "owners")?.Value?.ToLowerInvariant(); - - // Determine if it's open source based on multiple indicators - var isOpenSource = false; - - // Open source license indicators - var openSourceLicenses = new[] { - "mit", "apache", "bsd", "gpl", "lgpl", "mpl", "isc", "unlicense", - "cc0", "zlib", "ms-pl", "ms-rl", "eclipse", "cddl", "artistic" - }; - - if (!string.IsNullOrEmpty(license)) - { - isOpenSource = openSourceLicenses.Any(oss => license.Contains(oss)); - } - - if (!isOpenSource && !string.IsNullOrEmpty(licenseUrl)) - { - isOpenSource = openSourceLicenses.Any(oss => licenseUrl.Contains(oss)) || - licenseUrl.Contains("github.com") || - licenseUrl.Contains("opensource.org"); - } - - // Check repository URLs for open source indicators - if (!isOpenSource) - { - var urls = new[] { projectUrl, repositoryUrl }.Where(url => !string.IsNullOrEmpty(url)); - isOpenSource = urls.Any(url => - url!.Contains("github.com") || - url.Contains("gitlab.com") || - url.Contains("bitbucket.org") || - url.Contains("codeplex.com") || - url.Contains("sourceforge.net")); - } - - // Determine commercial status - // Microsoft packages are generally free but from a commercial entity - var isMicrosoft = packageId.StartsWith("microsoft.") || - packageId.StartsWith("system.") || - !string.IsNullOrEmpty(authors) && authors.Contains("microsoft") || - !string.IsNullOrEmpty(owners) && owners.Contains("microsoft"); - - // Other commercial indicators - var commercialIndicators = new[] { - "commercial", "proprietary", "enterprise", "professional", "premium", - "telerik", "devexpress", "syncfusion", "infragistics", "componentone" - }; - - var hasCommercialIndicators = commercialIndicators.Any(indicator => - (!string.IsNullOrEmpty(license) && license.Contains(indicator)) || - (!string.IsNullOrEmpty(authors) && authors.Contains(indicator)) || - (!string.IsNullOrEmpty(packageId) && packageId.Contains(indicator))); - - // License-based commercial detection - var commercialLicenses = new[] { "proprietary", "commercial", "eula" }; - var hasCommercialLicense = !string.IsNullOrEmpty(license) && - commercialLicenses.Any(cl => license.Contains(cl)); - - // Set source type - if (isOpenSource) - { - package.SourceType = "Open Source"; - } - else if (hasCommercialLicense || hasCommercialIndicators) - { - package.SourceType = "Closed Source"; - } - else if (isMicrosoft) - { - package.SourceType = "Closed Source"; // Microsoft packages are typically closed source even if free - } - else - { - package.SourceType = "Unknown"; - } - - // Set commercial status - if (hasCommercialLicense || hasCommercialIndicators) - { - package.Commercial = "Yes"; - } - else if (isOpenSource || isMicrosoft) - { - package.Commercial = "No"; - } - else - { - package.Commercial = "Unknown"; - } - } - /// /// Helper class to track package information across projects. /// diff --git a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs index ce86811..1cc0761 100644 --- a/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs +++ b/src/CodeMedic/Plugins/HealthAnalysis/RepositoryScanner.cs @@ -21,8 +21,8 @@ public class RepositoryScanner /// The root directory to scan. Defaults to current directory if null or empty. public RepositoryScanner(string? rootPath = null) { - _rootPath = string.IsNullOrWhiteSpace(rootPath) - ? Directory.GetCurrentDirectory() + _rootPath = string.IsNullOrWhiteSpace(rootPath) + ? Directory.GetCurrentDirectory() : Path.GetFullPath(rootPath); _nugetInspector = new NuGetInspector(_rootPath); _vulnerabilityScanner = new VulnerabilityScanner(_rootPath); @@ -54,6 +54,41 @@ public async Task> ScanAsync() // Scan for vulnerabilities after all projects are parsed await CollectVulnerabilitiesAsync(); + + // Check for any stale NuGet packages - packages that haven't been updated in over a year + foreach (var project in _projects) + { + foreach (var package in project.PackageDependencies) + { + // get the latest version info from nuget.org + var latestPublishedDate = await _nugetInspector.FetchLatestVersionPublishedDateAsync(package.Name); + + // log to the console the package name and published date + // Console.WriteLine($"Package: {package.Name}, Latest Published Date: {latestPublishedDate?.ToString("yyyy-MM-dd") ?? "Unknown"}"); + + if (latestPublishedDate.HasValue) + { + var age = DateTime.UtcNow - latestPublishedDate.Value; + if (age.TotalDays > 365) + { + + // Console.WriteLine($"Stale Package Detected: {package.Name}, Last Published: {latestPublishedDate.Value:yyyy-MM-dd}, Age: {age.TotalDays:F0} days"); + + // add this package to the stale packages metadata + if (!project.Metadata.ContainsKey("StalePackages")) + { + project.Metadata["StalePackages"] = new List<(string PackageName, DateTime PublishedDate)>(); + } + + // add to the list + var staleList = (List<(string PackageName, DateTime PublishedDate)>)project.Metadata["StalePackages"]; + staleList.Add((package.Name, latestPublishedDate.Value)); + + } + } + } + } + } catch (Exception ex) { @@ -126,7 +161,7 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) // this is redundant // summaryKvList.Add("Total Projects", totalProjects.ToString()); summaryKvList.Add("Production Projects", nonTestProjects.ToString()); - summaryKvList.Add("Test Projects", testProjectCount.ToString(), + summaryKvList.Add("Test Projects", testProjectCount.ToString(), testProjectCount > 0 ? TextStyle.Success : TextStyle.Warning); summaryKvList.Add("Total Lines of Code", totalLinesOfCode.ToString()); if (testProjectCount > 0) @@ -246,6 +281,47 @@ public ReportDocument GenerateReport(bool limitPackageLists = true) report.AddSection(noVulnSection); } + // Stale packages section + var stalePackages = new Dictionary(); + + foreach (var project in _projects) + { + if (project.Metadata.ContainsKey("StalePackages")) + { + var staleList = (List<(string PackageName, DateTime PublishedDate)>)project.Metadata["StalePackages"]; + foreach (var (PackageName, PublishedDate) in staleList) + { + if (!stalePackages.ContainsKey(PackageName)) + { + stalePackages[PackageName] = PublishedDate; + } + } + } + } + + if (stalePackages.Count > 0) + { + var staleSection = new ReportSection + { + Title = "Stale Packages", + Level = 1 + }; + staleSection.AddElement(new ReportParagraph( + "Consider updating these packages that haven't been updated in over a year.", + TextStyle.Warning)); + var staleList = new ReportList + { + Title = "Stale Packages" + }; + foreach (var kvp in stalePackages.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + var nugetUrl = $"https://www.nuget.org/packages/{kvp.Key}"; + staleList.AddItem($"{kvp.Key} (Last published: {kvp.Value:yyyy-MM-dd}) - {nugetUrl}"); + } + staleSection.AddElement(staleList); + report.AddSection(staleSection); + } + // Projects table section if (totalProjects > 0) { @@ -575,7 +651,7 @@ private async Task ParseProjectAsync(string projectFilePath) if (!projectInfo.IsTestProject) { var testFrameworkPackages = new[] { "xunit", "nunit", "mstest", "microsoft.net.test.sdk", "coverlet" }; - projectInfo.IsTestProject = projectInfo.PackageDependencies.Any(pkg => + projectInfo.IsTestProject = projectInfo.PackageDependencies.Any(pkg => testFrameworkPackages.Any(tfp => pkg.Name.Contains(tfp, StringComparison.OrdinalIgnoreCase))); } @@ -625,7 +701,7 @@ private static string InferDefaultCSharpVersion(string? targetFramework) // Mapping based on https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/configure-language-version#defaults // and .NET 10 preview announcements - if (tfm.StartsWith("net10.0")) return "14"; + if (tfm.StartsWith("net10.0")) return "14"; if (tfm.StartsWith("net9.0")) return "13"; if (tfm.StartsWith("net8.0")) return "12"; if (tfm.StartsWith("net7.0")) return "11"; @@ -756,8 +832,8 @@ private int CountLinesOfCode(string projectFilePath) { var projectDir = Path.GetDirectoryName(projectFilePath) ?? ""; var csFiles = Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories) - .Where(f => !Path.GetFileName(f).StartsWith(".") && - !f.Contains("\\.vs\\") && + .Where(f => !Path.GetFileName(f).StartsWith(".") && + !f.Contains("\\.vs\\") && !f.Contains("\\bin\\") && !f.Contains("\\obj\\") && !Path.GetFileName(f).EndsWith(".g.cs"))