diff --git a/.editorconfig b/.editorconfig index 869c176..2290e7e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,10 @@ root = true # EditorConfig is awesome: http://EditorConfig.org -# top-most EditorConfig file +#################################################################### +## Global settings +#################################################################### -# Global settings [*] end_of_line = crlf insert_final_newline = true @@ -110,13 +111,11 @@ csharp_space_after_dot = false csharp_space_after_keywords_in_control_flow_statements = true csharp_space_after_semicolon_in_for_statement = true csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = do_not_ignore csharp_space_before_colon_in_inheritance_clause = true csharp_space_before_comma = false csharp_space_before_dot = false csharp_space_before_open_square_brackets = false csharp_space_before_semicolon_in_for_statement = false -csharp_space_between_attribute_sections = false csharp_space_between_empty_square_brackets = false csharp_space_between_method_call_empty_parameter_list_parentheses = false csharp_space_between_method_call_name_and_opening_parenthesis = false @@ -127,222 +126,283 @@ csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_between_square_brackets = false +#################################################################### +## Rider/ReSharper Settings +#################################################################### + +# Keep enough blank lines between code blocks to make it readable. resharper_blank_lines_after_multiline_statements = 1 +resharper_blank_lines_around_single_line_auto_property = 1 +resharper_blank_lines_before_case = 1 # Purpose: Don't put elements of an array on new lines, unless the length exceeds the maximum line length. resharper_wrap_array_initializer_style = chop_always -# Start each element in a object or collection initializer on a new line +# Purpose: Always put elements of an object initializer on new lines, unless the length exceeds the maximum line length. resharper_wrap_object_and_collection_initializer_style = chop_always -# Purpose: An item within a C# enumeration is missing an Xml documentation header -# Reason: Some enums are self-explanatory -dotnet_diagnostic.ca1062.severity = suggestion +resharper_unused_parameter_local_highlighting = error +resharper_not_accessed_positional_property_global_highlighting = error -# Purpose: Specify StringComparison -# Reason: Ignored since we force invariant culture on the csproj level -dotnet_diagnostic.ca1307.severity = none +# Do not remove redundant else blocks +resharper_redundant_if_else_block_highlighting = none -# Purpose: Specify StringComparison -# Reason: Specify StringComparison for methods that don't use Ordinal by default -dotnet_diagnostic.ca1310.severity = error +# If we use explicit property names in anonymous types, we do that on purpose. +resharper_redundant_anonymous_type_property_name_highlighting = none -# Purpose: 'X' is an internal class that is apparently never instantiated. If so, remove the code from the assembly -# Reason: The class is used, but not directly through the constructor, which is fine. -dotnet_diagnostic.ca1812.severity = none +#################################################################### +## Roslyn Analyzers and Code Fixes +#################################################################### -# Purpose: Properties should not return arrays -# Reason: Although it's good advice, we should decide this case-by-case -dotnet_diagnostic.ca1819.severity = suggestion +# Purpose: Field contains the word 'and', which suggests doing multiple thing +# Rationale: We do not want to enforce this rule in our codebase. +dotnet_diagnostic.AV1115.severity = suggestion -# Purpose: For improved performance, use the LoggerMessage delegates instead of calling 'LoggerExtensions.LogInformation(ILogger, string?, params object?[])' -# Reason: For now, we prefer to use explicit checks using IsEnabled -dotnet_diagnostic.ca1848.severity = none +# Purpose: Return interfaces to unchangeable collections +# Rationale: We do not want to enforce this rule in our codebase. +dotnet_diagnostic.AV1130.severity = suggestion -# Purpose: Use Task.ConfigureAwait(false) if the current SynchronizationContext is not needed -# Reason: Not relevant for .NET Core web applications. -dotnet_diagnostic.ca2007.severity = none +# Purpose: null is returned from method which has return type of string, collection or task +# Rationale: It's a good suggestion, but can be hard to implement. +dotnet_diagnostic.AV1135.severity = suggestion -# Purpose: # SA0001: XmlCommentAnalysisDisabled -# Reason: We don't want to force this -dotnet_diagnostic.sa0001.severity = none +# Purpose contains x statements, which exceeds the maximum of 7 statements +# Rationale: 7 is the ideal Clean Code value, 15 is what we think is reasonable, 40 is the absolute maximum. +dotnet_diagnostic.AV1500.max_statement_count = 40 +dotnet_diagnostic.AV1500.severity = error -# Purpose: Use string.Empty for empty string -# Reason: We don't want to force this -dotnet_diagnostic.sa1122.severity = none +# Purpose: Loop statement contains nested loop +# Rationale: We don't want to completely prevent this. +dotnet_diagnostic.AV1532.severity = suggestion -# Purpose: Prefix local calls with this -# Reason: We don't want to force this -dotnet_diagnostic.sa1101.severity = none +# Purpose: Overloaded method should call another overload +# Rationale: Does often lead to more complex code +dotnet_diagnostic.AV1551.severity = suggestion -# Purpose: A enum should not follow a class -# Reason: We don't want to force this for a content-only package -dotnet_diagnostic.sa1201.severity = none +# Parameter is invoked with a named argument +# Rationale: We do not want to enforce this rule in our codebase. +dotnet_diagnostic.AV1555.severity = none -# Purpose: public' members should come before 'private' members -# Reason: We keep members in order of execution -dotnet_diagnostic.SA1202.severity = none +# Purpose: Don’t declare signatures with more than 3 parameters +# Rationale: 3 is the ideal number of parameters for a method, but more than 5 in a constructor is a problem +dotnet_diagnostic.AV1561.max_parameter_count = 5 +dotnet_diagnostic.AV1561.max_constructor_parameter_count = 8 +dotnet_diagnostic.AV1561.severity = error -# Purpose: Static members should appear before non-static members -# Reason: We keep members in order of execution -dotnet_diagnostic.SA1204.severity = none +# Purpose: Argument for parameter calls nested method +# Rationale: Modern debuggers allow stepping into nested calls, so no need to avoid them. +dotnet_diagnostic.AV1580.severity = none -# Purpose: Code should not contain multiple blank lines in a row -# Reason: No need to fail a build on that -dotnet_diagnostic.sa1507.severity = none +# Purpose: Parameter 'x' should have a more descriptive name +# Rationale: Not always useful +dotnet_diagnostic.AV1706.severity = suggestion -# Purpose: A single-line comment within C# code is not preceded by a blank line. -# Reason: Unnecessarily strict -dotnet_diagnostic.sa1515.severity = none +# Purpose: Field contains the name of its containing type +# Rationale: It's not smart enough to understand that CustomFormatters is not the same as Formatter +dotnet_diagnostic.AV1710.severity = suggestion -# Purpose: File is required to end with a single newline character -# Reason: We don't case -dotnet_diagnostic.sa1518.severity = none +# Purpose: Name of async method +# Rationale: We prefer to only use the "Async" suffix if a synchronous and asynchronous version of the method exists. +dotnet_diagnostic.AV1755.severity = none -# Purpose: Element parameters should be documented -# Reason: We don't want to force this -dotnet_diagnostic.sa1611.severity = none +# Purpose: Replace call to Nullable.HasValue with null check +# Rationale: Should be a suggestion, not an error +dotnet_diagnostic.AV2202.severity = suggestion -# Purpose: Element return value should be documented -# Reason: We don't want to force this -dotnet_diagnostic.sa1615.severity = none +# Purpose: Missing XML comment for internally visible type or member +# Rationale: We do not want to enforce this rule in our codebase. +dotnet_diagnostic.AV2305.severity = suggestion -# Purpose: Generic type parameters should be documented -# Reason: We don't want to force this -dotnet_diagnostic.sa1618.severity = none +# Purpose: Consider making 'RaiseXXX' an event +# Rationale: Should only be a suggestion +dotnet_diagnostic.CA1030.severity = suggestion -# Purpose: Element return value should be documented -# Reason: We don't want to force this -dotnet_diagnostic.sa1622.severity = none +# Purpose: Change the type of property 'from 'string' to 'System.Uri' +# Rationale: Not a bad idea, but not always practical in existing codebases. +dotnet_diagnostic.CA1056.severity = suggestion -# Purpose: The property's documentation summary text should begin with: 'Gets or sets' -# Reason: We don't want to force this -dotnet_diagnostic.sa1623.severity = none +# Purpose: In externally visible method validate parameter 'formattedGraph' is non-null before using it. +# Rationale: Not a bad idea, but not always practical in existing codebases. +dotnet_diagnostic.CA1062.severity = suggestion -# Purpose: Documentation text should end with a period -# Reason: We're not building a library -dotnet_diagnostic.sa1629.severity = none +# Purpose: Method passes a literal string as parameter +# Rationale: Very specific rule for localizable applications +dotnet_diagnostic.CA1303.severity = suggestion -# Purpose: The file header is missing or not located at the top of the file -# Reason: We don't want to force this -dotnet_diagnostic.sa1633.severity = none +# Purpose: Rename virtual/interface member so that it no longer conflicts with the reserved language keyword 'Throw'. +# Rationale: We only support C# +dotnet_diagnostic.CA1716.severity = none -# Purpose: File name should match first type name -# Reason: To keep the content-only package simple, we keep everything together -dotnet_diagnostic.sa1649.severity = none +# Purpose: Properties should not return arrays +# Rationale: Although returning arrays has some performance implications, we don't want to force this. +dotnet_diagnostic.CA1819.severity = suggestion -# Purpose: Elements should be documented -# Reason: We don't want to force documentation for all elements -dotnet_diagnostic.sa1600.severity = suggestion +# Purpose: Use concrete types when possible for improved performance +# Rationale: Does not need to be enforced in all cases, especially when using interfaces creates less coupling +dotnet_diagnostic.CA1859.severity = suggestion -# Purpose: Partial elements should be documented -# Reason: We don't want to force this -dotnet_diagnostic.sa1601.severity = none +# Purpose: For improved performance, use the LoggerMessage delegates instead of calling 'LoggerExtensions.LogInformation(ILogger, string?, params object?[])' +# Rationale: Reported as a suggestion to allow developers to consciously decide whether to optimize. +dotnet_diagnostic.CA1848.severity = suggestion -# Purpose: Enumeration items should be documented -# Reason: We don't want to force this -dotnet_diagnostic.sa1602.severity = suggestion +# Purpose: Use Task.ConfigureAwait(false) if the current SynchronizationContext is not needed +# Rationale: Not relevant for .NET Core web applications. +dotnet_diagnostic.CA2007.severity = none -# Purpose: Closing parenthesis should be on line of last parameter -# Reason: We don't want to force this -dotnet_diagnostic.sa1111.severity = none +# Purpose: Pass System.Uri objects instead of strings +# Rationale: Although nice to have, this rule is not always practical in existing codebases. +dotnet_diagnostic.CA2234.severity = suggestion -# Purpose: The parameters should begin on the line after the declaration, whenever the parameter span across multiple lines -# Reason: We don't want to force this -dotnet_diagnostic.sa1116.severity = none +# Purpose: Use string.Equals instead of Equals operator +# Rationale: Does not improve readability +dotnet_diagnostic.MA0006.severity = none -# Purpose: The parameters should all be placed on the same line or each parameter should be placed on its own lin -# Reason: We don't want to force this -dotnet_diagnostic.sa1117.severity = none +# Purpose: Regular expressions should not be vulnerable to Denial of Service attacks +# Rationale: See https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0009.md +dotnet_diagnostic.MA0009.severity = suggestion -# Purpose: Use an overload of 'Contains' that has a StringComparison parameter -# Reason: Ignored since we force invariant culture on the csproj level -dotnet_diagnostic.ma0001.severity = none +# Purpose: Use an overload of 'System.ArgumentException' with the parameter name +# Rationale: We don't want to force this +dotnet_diagnostic.MA0015.severity = suggestion -# Purpose: MA0002 : Use an overload that has a IEqualityComparer or IComparer parameter -# Reason: Ignored since we force invariant culture on the csproj level -dotnet_diagnostic.ma0002.severity = none +# Purpose: Use an explicit StringComparer to compute hash codes +# Rationale: Too farfetched +dotnet_diagnostic.MA0021.severity = suggestion -# Purpose: Use Task.ConfigureAwait(false) if the current SynchronizationContext is not needed -# Reason: Not relevant for .NET Core web applications. -dotnet_diagnostic.ma0004.severity = none +# Purpose: Closing parenthesis should not be preceded by a space +# Rationale: This conflicts with record constructors +dotnet_diagnostic.SA1009.severity = none -# Purpose: Use string.Equals instead of NotEquals operator -# Reason: Ignored since we force invariant culture on the csproj level -dotnet_diagnostic.ma0006.severity = none +# Purpose: Code should not contain trailing whitespace +# Rationale: We don't want to force this. +dotnet_diagnostic.SA1028.severity = none -# Purpose: MA0011 : Use an overload of 'ToString' that has a 'System.IFormatProvider' parameter -# Reason: Ignored since we force invariant culture on the csproj level -dotnet_diagnostic.ma0011.severity = none +# Purpose: Prefix local calls with this +# Rationale: We don't want to force this +dotnet_diagnostic.SA1101.severity = none -# Purpose: Fix TODO comment -# Reason: We don't want to force this -dotnet_diagnostic.ma0026.severity = suggestion +# Purpose: Generic type constraints should be on their own line +# Rationale: We don't want to force this +dotnet_diagnostic.SA1127.severity = none -# Purpose: File name must match type name. -# Reason: Too many purposeful violations. -dotnet_diagnostic.MA0048.severity = none +# Purpose: Ordering rules +# Rationale: We prefer to order members so we can read code like a book +dotnet_diagnostic.SA1201.severity = none +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1204.severity = none -# Purpose: Closing parenthesis should not be preceded by a space -# Reason: We don't want to force this -dotnet_diagnostic.sa1009.severity = none +# Purpose: The parameters should begin on the line after the declaration, whenever the parameter span across multiple lines +# Rationale: We don't want to force this +dotnet_diagnostic.SA1116.severity = none + +# Purpose: The parameters should all be placed on the same line or each parameter should be placed on its own lin +# Rationale: We don't want to force this +dotnet_diagnostic.SA1117.severity = none -# Purpose: Code should not contain trailing whitespace -# Reason: We don't want to force this -dotnet_diagnostic.sa1028.severity = none +# Purpose: Use string.Empty for empty string +# Rationale: There is no performance difference in modern .NET versions, so no need to enforce this. +dotnet_diagnostic.SA1122.severity = none + +# Purpose: Do not use region +# Rationale: Is up to the developer to decide if they want to use regions or not. +dotnet_diagnostic.SA1124.severity = none + +# Purpose: Variable '_' should begin with lower-case lette +# Rationale: Does not understand discard parameters +dotnet_diagnostic.SA1312.severity = none + +# Purpose: Parameter '_' should begin with lower-case lette +# Rationale: Does not understand discard parameters +dotnet_diagnostic.SA1313.severity = none # Purpose: File may only contain a single type -# Reason: We keep everything together in this project. -dotnet_diagnostic.SA1402.severity = none +# Rationale: Although we prefer to have one type per file, we don't want to force this. +dotnet_diagnostic.SA1402.severity = suggestion + +# Purpose: Code analysis suppression should have justification +# Rationale: If we do it, we have reasons for it. +dotnet_diagnostic.SA1404.severity = none # Purpose: Use trailing comma in multi-line initialize -# Reason: We don't want to force this -dotnet_diagnostic.sa1413.severity = none +# Rationale: We don't want to force this +dotnet_diagnostic.SA1413.severity = none -# Purpose: Return type in signature for should be an interface to an unchangeable collection -# Reason: We don't want to force this -dotnet_diagnostic.av1130.severity = none +# Purpose: A closing brace should not be preceded by a blank line +# Rationale: We don't care +dotnet_diagnostic.SA1508.severity = none -# Purpose: A property, method or local function should do only one thing -# Reason: We don't want to force this -dotnet_diagnostic.av1115.severity = suggestion +# Purpose: File is required to end with a single newline character ( +# Rationale: We don't care +dotnet_diagnostic.SA1518.severity = none -# Purpose: Method contains 8 statements, which exceeds the maximum of 7 statements -# Reason: We don't want to force this -dotnet_diagnostic.av1500.severity = none +# Purpose: Elements should be documented +# Rationale: We don't want to force documentation for all elements +dotnet_diagnostic.SA1600.severity = suggestion -# Purpose: Loop statement contains nested loop -# Reason: We don't want to completely prevent this. -dotnet_diagnostic.av1532.severity = suggestion +# Purpose: Partial elements should be documented +# Rationale: We don't want to force this +dotnet_diagnostic.SA1601.severity = suggestion -# Purpose: Call the more overloaded method from other overloads -# Reason: We don't want to force this -dotnet_diagnostic.av1551.severity = none +# Purpose: Enumeration items should be documented +# Rationale: We don't want to force this +dotnet_diagnostic.SA1602.severity = suggestion -# Purpose: Argument for parameter 'param' in method calls nested method -# Reason: Not a real issue in Rider anymore -dotnet_diagnostic.av1580.severity = none +# Purpose: Element parameters should be documented +# Rationale: We don't want to force this +dotnet_diagnostic.SA1611.severity = suggestion -# Purpose: Argument for parameter 'param' in method calls nested method -# Reason: Not a real issue in Rider anymore -dotnet_diagnostic.av1580.severity = none +# Purpose: The parameter documentation for 'because' should be at position 3 +# Rationale: We don't want to force this +dotnet_diagnostic.SA1612.severity = suggestion -# Purpose: AV1706 : Parameter 'p' should have a more descriptive name -# Reason: Also complains about lambda parameters. See https://github.com/bkoelman/CSharpGuidelinesAnalyzer/issues/147 -dotnet_diagnostic.av1706.severity = none +# Purpose: Element return value should be documented +# Rationale: We don't want to force this +dotnet_diagnostic.SA1615.severity = suggestion -# Purpose: Property 'Username' contains the name of its containing type 'User'. We should make that decision case-by-case -# Reason: We don't want to judge this case-by-case -dotnet_diagnostic.av1710.severity = none +# Purpose: Generic type parameters should be documented +# Rationale: We don't want to force this +dotnet_diagnostic.SA1618.severity = suggestion -# Purpose: Error AV1755 : Name of async method should end with Async or TaskAsync -# Reason: We don't want to force this -dotnet_diagnostic.av1755.severity = none +# Purpose: Element return value should be documented +# Rationale: We don't want to force this +dotnet_diagnostic.sa1622.severity = suggestion -# Purpose: Missing XML comment for internally visible type or member -# Reason: We don't want to force this -dotnet_diagnostic.av2305.severity = suggestion +# Purpose: The property's documentation summary text should begin with: 'Gets or sets' +# Rationale: We don't want to force this +dotnet_diagnostic.SA1623.severity = suggestion + +# Purpose: Documentation text should end with a period +# Rationale: We don't want to force this +dotnet_diagnostic.SA1629.severity = none -# Purpose: Pass -warnaserror to the compiler or add True to your project -# Reason: It is already enabled, but during a Sonar scan, the scanner switches back causing unnecessary warnings -dotnet_diagnostic.av2210.severity = none +# Purpose: The file header is missing or not located at the top of the file +# Rationale: We don't want to force this +dotnet_diagnostic.SA1633.severity = none + +# Purpose: Constructor summary documentation should begin with standard text +# Rationale: We don't want to force this +dotnet_diagnostic.SA1642.severity = none + +# Purpose: File name should match first type name +# Rationale: To keep the content-only package simple, we keep everything together +dotnet_diagnostic.SA1649.severity = none + +# Purpose: Use file header +# Rationale: We don't want to force this +dotnet_diagnostic.SA1633.severity = none + +#################################################################### +## Duplicate rules +#################################################################### + +dotnet_diagnostic.AV1210.severity = none # duplicate of CA1031 +dotnet_diagnostic.AV2407.severity = none # duplicate of SA1124 +dotnet_diagnostic.CA1050.severity = none # duplicate of MA0047 (which is more explanatory) +dotnet_diagnostic.MA0004.severity = none # duplicate of CA2007 +dotnet_diagnostic.MA0011.severity = none # duplicate of CA1305 +dotnet_diagnostic.MA0051.severity = none # duplicate of AV1500 +dotnet_diagnostic.MA0069.severity = none # duplicate of CA2211 +dotnet_diagnostic.RCS1102.severity = none # duplicate of CA1052 +dotnet_diagnostic.RCS1188.severity = none # duplicate of CA1805 +dotnet_diagnostic.RCS1194.severity = none # duplicate of CA1032 +dotnet_diagnostic.SA1401.severity = none # duplicate of CA1051 \ No newline at end of file diff --git a/Build/Configuration.cs b/Build/Configuration.cs index 78049f7..8d57b51 100644 --- a/Build/Configuration.cs +++ b/Build/Configuration.cs @@ -4,8 +4,15 @@ [TypeConverter(typeof(TypeConverter))] public class Configuration : Enumeration { - public static Configuration Debug = new() { Value = nameof(Debug) }; - public static Configuration Release = new() { Value = nameof(Release) }; + public static Configuration Debug = new() + { + Value = nameof(Debug) + }; + + public static Configuration Release = new() + { + Value = nameof(Release) + }; public static implicit operator string(Configuration configuration) { diff --git a/Src/PackageGuard.Core/CSharp/CSharpProjectAnalysisStrategy.cs b/Src/PackageGuard.Core/CSharp/CSharpProjectAnalysisStrategy.cs index fc8a533..986cf07 100644 --- a/Src/PackageGuard.Core/CSharp/CSharpProjectAnalysisStrategy.cs +++ b/Src/PackageGuard.Core/CSharp/CSharpProjectAnalysisStrategy.cs @@ -231,7 +231,8 @@ private static Dictionary BuildDependencyKeys(LockFile lockFil .Select(dependency => target.Libraries.FirstOrDefault(l => string.Equals(l.Name, dependency.Id, StringComparison.OrdinalIgnoreCase))) .Where(dependencyLibrary => dependencyLibrary is not null) - .Select(dependencyLibrary => PackageInfo.CreatePackageKey(dependencyLibrary!.Name!, dependencyLibrary.Version!.ToNormalizedString())) + .Select(dependencyLibrary => + PackageInfo.CreatePackageKey(dependencyLibrary!.Name!, dependencyLibrary.Version!.ToNormalizedString())) .ToArray(); if (library.Version is not null && !string.IsNullOrWhiteSpace(library.Name)) @@ -270,5 +271,4 @@ private static HashSet FindPackagesDependingOnPreOneZeroPackages(LockFil return result; } - } diff --git a/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs b/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs index 84b612f..e2321b5 100644 --- a/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs +++ b/Src/PackageGuard.Core/CSharp/NuGetPackageAnalyzer.cs @@ -154,7 +154,8 @@ private void EnsureCredentialProvidersConfigured() { if (!credentialProvidersConfigured) { - DefaultCredentialServiceUtility.SetupDefaultCredentialService(NullLogger.Instance, nonInteractive: !InteractiveRestore); + DefaultCredentialServiceUtility.SetupDefaultCredentialService(NullLogger.Instance, + nonInteractive: !InteractiveRestore); credentialProvidersConfigured = true; } diff --git a/Src/PackageGuard.Core/CiHealthRiskFactor.cs b/Src/PackageGuard.Core/CiHealthRiskFactor.cs index a0c0409..9df9f88 100644 --- a/Src/PackageGuard.Core/CiHealthRiskFactor.cs +++ b/Src/PackageGuard.Core/CiHealthRiskFactor.cs @@ -38,7 +38,8 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (package.HasFlakyWorkflowPattern is true) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("CI workflow history shows a potentially flaky failure pattern", 0.5)); + rationale.Add(RiskEvaluationHelpers.CreateRationale("CI workflow history shows a potentially flaky failure pattern", + 0.5)); } if (package.HasRecentSuccessfulWorkflowRun is false) @@ -54,11 +55,14 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (package.RequiredStatusCheckCount is 0) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("No required status checks were detected on the default branch", 0.5)); + rationale.Add(RiskEvaluationHelpers.CreateRationale("No required status checks were detected on the default branch", + 0.5)); } else if (package.RequiredStatusCheckCount is > 0) { - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Required status checks are configured ({package.RequiredStatusCheckCount})", 0.0)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale( + $"Required status checks are configured ({package.RequiredStatusCheckCount})", 0.0)); } if (package.WorkflowPlatformCount is < 2) diff --git a/Src/PackageGuard.Core/ContributorHealthRiskFactor.cs b/Src/PackageGuard.Core/ContributorHealthRiskFactor.cs index 8f28182..241e8bf 100644 --- a/Src/PackageGuard.Core/ContributorHealthRiskFactor.cs +++ b/Src/PackageGuard.Core/ContributorHealthRiskFactor.cs @@ -30,12 +30,14 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (package.RecentMaintainerCount is < 2) { risk += 1.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Very few active maintainers in the last 6 months ({package.RecentMaintainerCount})", 1.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Very few active maintainers in the last 6 months ({package.RecentMaintainerCount})", 1.0)); } else if (package.RecentMaintainerCount is < 4) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Limited active maintainer pool in the last 6 months ({package.RecentMaintainerCount})", 0.5)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Limited active maintainer pool in the last 6 months ({package.RecentMaintainerCount})", 0.5)); } if (package.MedianMaintainerActivityDays is > 180) diff --git a/Src/PackageGuard.Core/DependencyHealthCountEnricher.cs b/Src/PackageGuard.Core/DependencyHealthCountEnricher.cs index 5ca6f9a..bd42ccc 100644 --- a/Src/PackageGuard.Core/DependencyHealthCountEnricher.cs +++ b/Src/PackageGuard.Core/DependencyHealthCountEnricher.cs @@ -20,13 +20,12 @@ internal sealed class DependencyHealthCountEnricher(IReadOnlyDictionary(StringComparer.OrdinalIgnoreCase); - (int staleCount, int abandonedCount, int deprecatedCount, int unmaintainedCriticalCount) = - CountDependencyHealth(package, visited); + DependencyHealthCounts counts = CountDependencyHealth(package, visited); - package.StaleTransitiveDependencyCount = staleCount; - package.AbandonedTransitiveDependencyCount = abandonedCount; - package.DeprecatedTransitiveDependencyCount = deprecatedCount; - package.UnmaintainedCriticalTransitiveDependencyCount = unmaintainedCriticalCount; + package.StaleTransitiveDependencyCount = counts.StaleCount; + package.AbandonedTransitiveDependencyCount = counts.AbandonedCount; + package.DeprecatedTransitiveDependencyCount = counts.DeprecatedCount; + package.UnmaintainedCriticalTransitiveDependencyCount = counts.UnmaintainedCriticalCount; return Task.CompletedTask; } @@ -35,8 +34,7 @@ public Task EnrichAsync(PackageInfo package) /// Recursively counts the number of unique stale, abandoned, deprecated, and unmaintained-critical /// transitive dependencies of , avoiding cycles via . /// - private (int staleCount, int abandonedCount, int deprecatedCount, int unmaintainedCriticalCount) CountDependencyHealth( - PackageInfo package, HashSet visited) + private DependencyHealthCounts CountDependencyHealth(PackageInfo package, HashSet visited) { int staleCount = 0; int abandonedCount = 0; @@ -75,15 +73,15 @@ public Task EnrichAsync(PackageInfo package) unmaintainedCriticalCount++; } - (int nestedStale, int nestedAbandoned, int nestedDeprecated, int nestedUnmaintainedCritical) = - CountDependencyHealth(dependency, visited); - staleCount += nestedStale; - abandonedCount += nestedAbandoned; - deprecatedCount += nestedDeprecated; - unmaintainedCriticalCount += nestedUnmaintainedCritical; + DependencyHealthCounts nested = CountDependencyHealth(dependency, visited); + + staleCount += nested.StaleCount; + abandonedCount += nested.AbandonedCount; + deprecatedCount += nested.DeprecatedCount; + unmaintainedCriticalCount += nested.UnmaintainedCriticalCount; } - return (staleCount, abandonedCount, deprecatedCount, unmaintainedCriticalCount); + return new DependencyHealthCounts(staleCount, abandonedCount, deprecatedCount, unmaintainedCriticalCount); } /// diff --git a/Src/PackageGuard.Core/DependencyHealthCounts.cs b/Src/PackageGuard.Core/DependencyHealthCounts.cs new file mode 100644 index 0000000..0afd04c --- /dev/null +++ b/Src/PackageGuard.Core/DependencyHealthCounts.cs @@ -0,0 +1,10 @@ +namespace PackageGuard.Core; + +/// +/// Holds the counts of transitive dependency health issues for a package. +/// +internal sealed record DependencyHealthCounts( + int StaleCount, + int AbandonedCount, + int DeprecatedCount, + int UnmaintainedCriticalCount); diff --git a/Src/PackageGuard.Core/DocumentationRiskFactor.cs b/Src/PackageGuard.Core/DocumentationRiskFactor.cs index 527157e..eb68863 100644 --- a/Src/PackageGuard.Core/DocumentationRiskFactor.cs +++ b/Src/PackageGuard.Core/DocumentationRiskFactor.cs @@ -56,6 +56,7 @@ public RiskFactorContribution Evaluate(PackageInfo package) bool hasAcceptableReleaseHistory = package.HasReleaseNotes is true || (package.HasChangelog is true && package.HasDefaultChangelog is not true); + if (!hasAcceptableReleaseHistory) { risk += 0.5; diff --git a/Src/PackageGuard.Core/GitHubRepositoryRiskEnricher.cs b/Src/PackageGuard.Core/GitHubRepositoryRiskEnricher.cs index d566817..70a2f20 100644 --- a/Src/PackageGuard.Core/GitHubRepositoryRiskEnricher.cs +++ b/Src/PackageGuard.Core/GitHubRepositoryRiskEnricher.cs @@ -4,6 +4,7 @@ using System.Text.RegularExpressions; using JetBrains.Annotations; using Microsoft.Extensions.Logging; +using NuGet.Versioning; namespace PackageGuard.Core; @@ -17,7 +18,8 @@ internal sealed class GitHubRepositoryRiskEnricher(ILogger logger, string? gitHu private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase); /// In-flight load tasks per repository API root, preventing duplicate concurrent fetches. - private static readonly Dictionary> InFlightLoads = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary> InFlightLoads = + new(StringComparer.OrdinalIgnoreCase); /// Lock for thread-safe cache and in-flight loads access. private static readonly Lock CacheLock = new(); @@ -31,6 +33,13 @@ static GitHubRepositoryRiskEnricher() /// Returns true if GitHub risk data is already populated for the package. public bool HasCachedData(PackageInfo package) => package.HasGitHubRiskData; + private sealed record RepositoryOwnerContext( + string OwnerLogin, + string RepositoryName, + bool OwnerIsOrganization, + string CanonicalUrl, + DateTimeOffset? OwnerCreatedAt); + /// Fetches GitHub repository risk data and applies it to the package. public async Task EnrichAsync(PackageInfo package) { @@ -46,6 +55,13 @@ public async Task EnrichAsync(PackageInfo package) return; } + ApplyOwnerAndDocumentationData(package, cached); + ApplyActivityAndWorkflowData(package, cached); + } + + /// Applies owner, contributor, documentation, and issue data from cached GitHub data. + private static void ApplyOwnerAndDocumentationData(PackageInfo package, GitHubRepositoryRiskData cached) + { package.OwnerIsOrganization = cached.OwnerIsOrganization; package.OwnerCreatedAt = cached.OwnerCreatedAt; package.ContributorCount = cached.ContributorCount; @@ -75,6 +91,11 @@ public async Task EnrichAsync(PackageInfo package) package.ExternalContributionRate = cached.ExternalContributionRate; package.UniqueReviewerCount = cached.UniqueReviewerCount; package.ReviewerDiversityRatio = cached.ReviewerDiversityRatio; + } + + /// Applies workflow, supply-chain, and release data from cached GitHub data. + private static void ApplyActivityAndWorkflowData(PackageInfo package, GitHubRepositoryRiskData cached) + { package.RecentFailedWorkflowCount = cached.RecentFailedWorkflowCount; package.HasRecentSuccessfulWorkflowRun = cached.HasRecentSuccessfulWorkflowRun; package.WorkflowFailureRate = cached.WorkflowFailureRate; @@ -154,6 +175,7 @@ public async Task EnrichAsync(PackageInfo package) string repositoryName = repo.GetProperty("name").GetString() ?? string.Empty; bool ownerIsOrganization = string.Equals(repo.GetProperty("owner").GetProperty("type").GetString(), "Organization", StringComparison.OrdinalIgnoreCase); + string canonicalUrl = repo.TryGetProperty("html_url", out JsonElement htmlUrlElement) ? htmlUrlElement.GetString() ?? string.Empty : string.Empty; @@ -164,119 +186,135 @@ public async Task EnrichAsync(PackageInfo package) DateTimeOffset? ownerCreatedAt = TryReadDate(ownerDocument.RootElement, "created_at"); - Task releaseDataTask = GetReleaseDataAsync(repositoryApiRoot); - Task readmeTask = TryGetReadmeDataAsync(repositoryApiRoot); - Task rootFilesTask = GetRootFilesAsync(repositoryApiRoot, defaultBranch); - Task issueDataTask = GetIssueDataAsync(repositoryApiRoot); - Task contributorDataTask = GetContributorDataAsync(repositoryApiRoot); - Task commitHealthTask = GetCommitHealthDataAsync(repositoryApiRoot, defaultBranch); - Task medianPullRequestMergeDaysTask = GetMedianPullRequestMergeDaysAsync(repositoryApiRoot); - Task pullRequestQualityTask = GetPullRequestQualityDataAsync(repositoryApiRoot); - Task workflowDataTask = GetWorkflowDataAsync(repositoryApiRoot, defaultBranch); - Task branchProtectionTask = GetBranchProtectionDataAsync(repositoryApiRoot, defaultBranch); - Task scorecardTask = TryGetScorecardDataAsync(ownerLogin, repositoryName); - - string[] rootFiles = await rootFilesTask; - Task changelogTask = GetChangelogDataAsync(repositoryApiRoot, defaultBranch, rootFiles); - Task securityPolicyTask = GetSecurityPolicyDataAsync(repositoryApiRoot, defaultBranch, rootFiles); - Task workflowFileSignalsTask = rootFiles.Contains(".github", StringComparer.OrdinalIgnoreCase) - ? GetWorkflowFileSignalsAsync(repositoryApiRoot, defaultBranch, rootFiles) - : Task.FromResult(new GitHubWorkflowFileSignals()); - string? readmeFileName = rootFiles.FirstOrDefault(file => file.StartsWith("README", StringComparison.OrdinalIgnoreCase)); - Task readmeUpdatedTask = string.IsNullOrWhiteSpace(readmeFileName) - ? Task.FromResult(null) - : TryGetLatestCommitDateAsync(repositoryApiRoot, readmeFileName, defaultBranch); - Task changelogUpdatedTask = rootFiles.Any(IsChangelogFile) - ? TryGetLatestCommitDateAsync(repositoryApiRoot, rootFiles.First(IsChangelogFile), defaultBranch) - : Task.FromResult(null); - - await Task.WhenAll(releaseDataTask, readmeTask, issueDataTask, contributorDataTask, commitHealthTask, - pullRequestQualityTask, - medianPullRequestMergeDaysTask, workflowDataTask, branchProtectionTask, scorecardTask, changelogTask, - securityPolicyTask, workflowFileSignalsTask, readmeUpdatedTask, changelogUpdatedTask); - - GitHubReleaseData releaseData = await releaseDataTask; - GitHubReadmeData readmeData = await readmeTask; - GitHubIssueData issueData = await issueDataTask; - GitHubContributorData contributorData = await contributorDataTask; - GitHubCommitHealthData commitHealthData = await commitHealthTask; - GitHubPullRequestQualityData pullRequestQualityData = await pullRequestQualityTask; - double? medianPullRequestMergeDays = await medianPullRequestMergeDaysTask; - GitHubWorkflowData workflowData = await workflowDataTask; - GitHubBranchProtectionData branchProtectionData = await branchProtectionTask; - GitHubChangelogData changelogData = await changelogTask; - GitHubSecurityPolicyData securityPolicyData = await securityPolicyTask; - GitHubWorkflowFileSignals workflowFileSignals = await workflowFileSignalsTask; - GitHubScorecardData scorecardData = await scorecardTask; - DateTimeOffset? readmeUpdatedAt = await readmeUpdatedTask; - DateTimeOffset? changelogUpdatedAt = await changelogUpdatedTask; - - return new GitHubRepositoryRiskData - { - CanonicalUrl = canonicalUrl, - OwnerIsOrganization = ownerIsOrganization, - OwnerCreatedAt = ownerCreatedAt, - ContributorCount = contributorData.ContributorCount, - TopContributorShare = contributorData.TopContributorShare, - TopTwoContributorShare = contributorData.TopTwoContributorShare, - RecentMaintainerCount = commitHealthData.RecentMaintainerCount, - HasReadme = readmeData.Exists, - HasDefaultReadme = readmeData.IsDefault, - ReadmeUpdatedAt = readmeUpdatedAt, - HasContributingGuide = rootFiles.Contains("CONTRIBUTING.md", StringComparer.OrdinalIgnoreCase), - HasSecurityPolicy = securityPolicyData.Exists, - HasDetailedSecurityPolicy = securityPolicyData.IsDetailed, - HasCoordinatedDisclosure = securityPolicyData.HasCoordinatedDisclosure, - HasChangelog = changelogData.Exists, - HasDefaultChangelog = changelogData.IsDefault, - ChangelogUpdatedAt = changelogUpdatedAt, - OpenBugIssueCount = issueData.OpenBugIssueCount, - StaleCriticalBugIssueCount = issueData.StaleCriticalBugIssueCount, - MedianIssueResponseDays = issueData.MedianIssueResponseDays, - MedianCriticalIssueResponseDays = issueData.MedianCriticalIssueResponseDays, - IssueResponseCoverage = issueData.IssueResponseCoverage, - MedianOpenBugAgeDays = issueData.MedianOpenBugAgeDays, - ClosedBugIssueCountLast90Days = issueData.ClosedBugIssueCountLast90Days, - ReopenedBugIssueCountLast90Days = issueData.ReopenedBugIssueCountLast90Days, - IssueTriageWithinSevenDaysRate = issueData.TriageWithinSevenDaysRate, - MedianPullRequestMergeDays = medianPullRequestMergeDays, - ExternalContributionRate = pullRequestQualityData.ExternalContributionRate, - UniqueReviewerCount = pullRequestQualityData.UniqueReviewerCount, - ReviewerDiversityRatio = pullRequestQualityData.ReviewerDiversityRatio, - RecentFailedWorkflowCount = workflowData.RecentFailedWorkflowCount, - HasRecentSuccessfulWorkflowRun = workflowData.HasRecentSuccessfulWorkflowRun, - WorkflowFailureRate = workflowData.FailureRate, - HasFlakyWorkflowPattern = workflowData.HasFlakyPattern, - RequiredStatusCheckCount = branchProtectionData.RequiredStatusCheckCount, - WorkflowPlatformCount = workflowFileSignals.PlatformCount, - HasCoverageWorkflowSignal = workflowFileSignals.HasCoverageSignal, - HasReproducibleBuildSignal = workflowFileSignals.HasReproducibleBuildSignal || scorecardData.BinaryArtifactsScore >= 8.0, - HasDependencyUpdateAutomation = workflowFileSignals.HasDependencyUpdateAutomation, - HasTestSignal = workflowFileSignals.HasTestSignal, - OpenSsfScore = scorecardData.Score, - HasBranchProtection = branchProtectionData.IsProtected ?? scorecardData.HasBranchProtection, - HasProvenanceAttestation = workflowFileSignals.HasProvenanceAttestation, - HasVerifiedReleaseSignature = releaseData.HasVerifiedReleaseSignature, - HasVerifiedPublisher = ownerIsOrganization, - HasReleaseNotes = releaseData.HasReleaseNotes, - HasSemVerReleaseTags = releaseData.HasSemVerReleaseTags, - MeanReleaseIntervalDays = releaseData.MeanReleaseIntervalDays, - MajorReleaseRatio = releaseData.MajorReleaseRatio, - PrereleaseRatio = releaseData.PrereleaseRatio, - RapidReleaseCorrectionCount = releaseData.RapidReleaseCorrectionCount, - VerifiedCommitRatio = commitHealthData.VerifiedCommitRatio, - MedianMaintainerActivityDays = commitHealthData.MedianMaintainerActivityDays, - LastReleaseAt = releaseData.LastReleaseAt - }; + var ownerContext = new RepositoryOwnerContext( + ownerLogin, repositoryName, ownerIsOrganization, canonicalUrl, ownerCreatedAt); + + return await FetchAndAssembleRepositoryDataAsync(repositoryApiRoot, defaultBranch, ownerContext); } catch (Exception ex) { logger.LogDebug("Failed to fetch GitHub repository risk metadata from {RepositoryApiRoot}: {Error}", repositoryApiRoot, ex.Message); + return null; } } + /// Fans out all parallel data fetches and assembles the final GitHubRepositoryRiskData record. + private async Task FetchAndAssembleRepositoryDataAsync( + string repositoryApiRoot, + string defaultBranch, + RepositoryOwnerContext ownerContext) + { + Task releaseDataTask = GetReleaseDataAsync(repositoryApiRoot); + Task readmeTask = TryGetReadmeDataAsync(repositoryApiRoot); + Task rootFilesTask = GetRootFilesAsync(repositoryApiRoot, defaultBranch); + Task issueDataTask = GetIssueDataAsync(repositoryApiRoot); + Task contributorDataTask = GetContributorDataAsync(repositoryApiRoot); + Task commitHealthTask = GetCommitHealthDataAsync(repositoryApiRoot, defaultBranch); + Task medianPrMergeDaysTask = GetMedianPullRequestMergeDaysAsync(repositoryApiRoot); + Task pullRequestQualityTask = GetPullRequestQualityDataAsync(repositoryApiRoot); + Task workflowDataTask = GetWorkflowDataAsync(repositoryApiRoot, defaultBranch); + Task branchProtectionTask = + GetBranchProtectionDataAsync(repositoryApiRoot, defaultBranch); + Task scorecardTask = + TryGetScorecardDataAsync(ownerContext.OwnerLogin, ownerContext.RepositoryName); + + string[] rootFiles = await rootFilesTask; + Task changelogTask = GetChangelogDataAsync(repositoryApiRoot, defaultBranch, rootFiles); + Task securityPolicyTask = + GetSecurityPolicyDataAsync(repositoryApiRoot, defaultBranch, rootFiles); + Task workflowFileSignalsTask = + rootFiles.Contains(".github", StringComparer.OrdinalIgnoreCase) + ? GetWorkflowFileSignalsAsync(repositoryApiRoot, defaultBranch, rootFiles) + : Task.FromResult(new GitHubWorkflowFileSignals()); + + string? readmeFileName = + rootFiles.FirstOrDefault(file => file.StartsWith("README", StringComparison.OrdinalIgnoreCase)); + Task readmeUpdatedTask = string.IsNullOrWhiteSpace(readmeFileName) + ? Task.FromResult(null) + : TryGetLatestCommitDateAsync(repositoryApiRoot, readmeFileName, defaultBranch); + Task changelogUpdatedTask = rootFiles.Any(IsChangelogFile) + ? TryGetLatestCommitDateAsync(repositoryApiRoot, rootFiles.First(IsChangelogFile), defaultBranch) + : Task.FromResult(null); + + await Task.WhenAll(releaseDataTask, readmeTask, issueDataTask, contributorDataTask, commitHealthTask, + pullRequestQualityTask, medianPrMergeDaysTask, workflowDataTask, branchProtectionTask, scorecardTask, + changelogTask, securityPolicyTask, workflowFileSignalsTask, readmeUpdatedTask, changelogUpdatedTask); + + GitHubReleaseData releaseData = await releaseDataTask; + GitHubReadmeData readmeData = await readmeTask; + GitHubIssueData issueData = await issueDataTask; + GitHubContributorData contributorData = await contributorDataTask; + GitHubCommitHealthData commitHealthData = await commitHealthTask; + GitHubPullRequestQualityData pullRequestQualityData = await pullRequestQualityTask; + GitHubWorkflowData workflowData = await workflowDataTask; + GitHubBranchProtectionData branchProtectionData = await branchProtectionTask; + GitHubChangelogData changelogData = await changelogTask; + GitHubSecurityPolicyData securityPolicyData = await securityPolicyTask; + GitHubWorkflowFileSignals workflowFileSignals = await workflowFileSignalsTask; + GitHubScorecardData scorecardData = await scorecardTask; + + return new GitHubRepositoryRiskData + { + CanonicalUrl = ownerContext.CanonicalUrl, + OwnerIsOrganization = ownerContext.OwnerIsOrganization, + OwnerCreatedAt = ownerContext.OwnerCreatedAt, + ContributorCount = contributorData.ContributorCount, + TopContributorShare = contributorData.TopContributorShare, + TopTwoContributorShare = contributorData.TopTwoContributorShare, + RecentMaintainerCount = commitHealthData.RecentMaintainerCount, + HasReadme = readmeData.Exists, + HasDefaultReadme = readmeData.IsDefault, + ReadmeUpdatedAt = await readmeUpdatedTask, + HasContributingGuide = rootFiles.Contains("CONTRIBUTING.md", StringComparer.OrdinalIgnoreCase), + HasSecurityPolicy = securityPolicyData.Exists, + HasDetailedSecurityPolicy = securityPolicyData.IsDetailed, + HasCoordinatedDisclosure = securityPolicyData.HasCoordinatedDisclosure, + HasChangelog = changelogData.Exists, + HasDefaultChangelog = changelogData.IsDefault, + ChangelogUpdatedAt = await changelogUpdatedTask, + OpenBugIssueCount = issueData.OpenBugIssueCount, + StaleCriticalBugIssueCount = issueData.StaleCriticalBugIssueCount, + MedianIssueResponseDays = issueData.MedianIssueResponseDays, + MedianCriticalIssueResponseDays = issueData.MedianCriticalIssueResponseDays, + IssueResponseCoverage = issueData.IssueResponseCoverage, + MedianOpenBugAgeDays = issueData.MedianOpenBugAgeDays, + ClosedBugIssueCountLast90Days = issueData.ClosedBugIssueCountLast90Days, + ReopenedBugIssueCountLast90Days = issueData.ReopenedBugIssueCountLast90Days, + IssueTriageWithinSevenDaysRate = issueData.TriageWithinSevenDaysRate, + MedianPullRequestMergeDays = await medianPrMergeDaysTask, + ExternalContributionRate = pullRequestQualityData.ExternalContributionRate, + UniqueReviewerCount = pullRequestQualityData.UniqueReviewerCount, + ReviewerDiversityRatio = pullRequestQualityData.ReviewerDiversityRatio, + RecentFailedWorkflowCount = workflowData.RecentFailedWorkflowCount, + HasRecentSuccessfulWorkflowRun = workflowData.HasRecentSuccessfulWorkflowRun, + WorkflowFailureRate = workflowData.FailureRate, + HasFlakyWorkflowPattern = workflowData.HasFlakyPattern, + RequiredStatusCheckCount = branchProtectionData.RequiredStatusCheckCount, + WorkflowPlatformCount = workflowFileSignals.PlatformCount, + HasCoverageWorkflowSignal = workflowFileSignals.HasCoverageSignal, + HasReproducibleBuildSignal = + workflowFileSignals.HasReproducibleBuildSignal || scorecardData.BinaryArtifactsScore >= 8.0, + HasDependencyUpdateAutomation = workflowFileSignals.HasDependencyUpdateAutomation, + HasTestSignal = workflowFileSignals.HasTestSignal, + OpenSsfScore = scorecardData.Score, + HasBranchProtection = branchProtectionData.IsProtected ?? scorecardData.HasBranchProtection, + HasProvenanceAttestation = workflowFileSignals.HasProvenanceAttestation, + HasVerifiedReleaseSignature = releaseData.HasVerifiedReleaseSignature, + HasVerifiedPublisher = ownerContext.OwnerIsOrganization, + HasReleaseNotes = releaseData.HasReleaseNotes, + HasSemVerReleaseTags = releaseData.HasSemVerReleaseTags, + MeanReleaseIntervalDays = releaseData.MeanReleaseIntervalDays, + MajorReleaseRatio = releaseData.MajorReleaseRatio, + PrereleaseRatio = releaseData.PrereleaseRatio, + RapidReleaseCorrectionCount = releaseData.RapidReleaseCorrectionCount, + VerifiedCommitRatio = commitHealthData.VerifiedCommitRatio, + MedianMaintainerActivityDays = commitHealthData.MedianMaintainerActivityDays, + LastReleaseAt = releaseData.LastReleaseAt + }; + } + /// Sends an authenticated GET request and parses the JSON response. private async Task GetJsonAsync(string url) { @@ -296,7 +334,9 @@ private async Task GetJsonAsync(string url) /// Lists filenames in the repository root directory. private async Task GetRootFilesAsync(string repositoryApiRoot, string defaultBranch) { - using JsonDocument contents = await GetJsonAsync($"{repositoryApiRoot}/contents?ref={Uri.EscapeDataString(defaultBranch)}"); + using JsonDocument contents = + await GetJsonAsync($"{repositoryApiRoot}/contents?ref={Uri.EscapeDataString(defaultBranch)}"); + if (contents.RootElement.ValueKind != JsonValueKind.Array) { return []; @@ -335,15 +375,24 @@ private async Task TryGetReadmeDataAsync(string repositoryApiR } catch (HttpRequestException) { - return new GitHubReadmeData { Exists = false }; + return new GitHubReadmeData + { + Exists = false + }; } catch (FormatException) { - return new GitHubReadmeData { Exists = false }; + return new GitHubReadmeData + { + Exists = false + }; } catch (DecoderFallbackException) { - return new GitHubReadmeData { Exists = false }; + return new GitHubReadmeData + { + Exists = false + }; } } @@ -401,7 +450,8 @@ private async Task GetIssueDataAsync(string repositoryApiRoot) .Where(issue => !issue.TryGetProperty("pull_request", out _)) .Select(issue => new GitHubIssueSnapshot { - Number = issue.TryGetProperty("number", out JsonElement numberElement) && numberElement.TryGetInt32(out int number) + Number = issue.TryGetProperty("number", out JsonElement numberElement) && + numberElement.TryGetInt32(out int number) ? number : 0, CreatedAt = TryReadDate(issue, "created_at") ?? now, @@ -437,6 +487,7 @@ private async Task GetIssueDataAsync(string repositoryApiRoot) responseDays.AddRange(responseResults .Where(result => result.ResponseDays.HasValue) .Select(result => result.ResponseDays!.Value)); + criticalResponseDays.AddRange(openIssues.Zip(responseResults) .Where(pair => pair.First.IsCritical && pair.Second.ResponseDays.HasValue) .Select(pair => pair.Second.ResponseDays!.Value)); @@ -502,7 +553,9 @@ private async Task TryGetIssueResponseDataAsync(GitHubI /// Fetches merged PRs and computes median merge time in days. private async Task GetMedianPullRequestMergeDaysAsync(string repositoryApiRoot) { - using JsonDocument pullsDoc = await GetJsonAsync($"{repositoryApiRoot}/pulls?state=closed&sort=updated&direction=desc&per_page=100"); + using JsonDocument pullsDoc = + await GetJsonAsync($"{repositoryApiRoot}/pulls?state=closed&sort=updated&direction=desc&per_page=100"); + if (pullsDoc.RootElement.ValueKind != JsonValueKind.Array) { return null; @@ -559,7 +612,8 @@ prereleaseElement.ValueKind is JsonValueKind.True or JsonValueKind.False && .Select(date => date!.Value) .OrderBy(date => date) .ToList(); - List<(DateTimeOffset PublishedAt, NuGet.Versioning.NuGetVersion Version)> parsedReleaseVersions = []; + + List<(DateTimeOffset PublishedAt, NuGetVersion Version)> parsedReleaseVersions = []; foreach (JsonElement release in releases) { @@ -567,7 +621,7 @@ prereleaseElement.ValueKind is JsonValueKind.True or JsonValueKind.False && ? tagNameElement.GetString() : null; - if (!TryParseReleaseVersion(tagName, out NuGet.Versioning.NuGetVersion? parsedVersion) || parsedVersion is null) + if (!TryParseReleaseVersion(tagName, out NuGetVersion? parsedVersion) || parsedVersion is null) { continue; } @@ -620,7 +674,9 @@ private async Task GetPullRequestQualityDataAsync( { try { - using JsonDocument pullsDoc = await GetJsonAsync($"{repositoryApiRoot}/pulls?state=closed&sort=updated&direction=desc&per_page=30"); + using JsonDocument pullsDoc = + await GetJsonAsync($"{repositoryApiRoot}/pulls?state=closed&sort=updated&direction=desc&per_page=30"); + if (pullsDoc.RootElement.ValueKind != JsonValueKind.Array) { return new GitHubPullRequestQualityData(); @@ -631,8 +687,13 @@ private async Task GetPullRequestQualityDataAsync( mergedAtElement.ValueKind == JsonValueKind.String) .Select(pr => new GitHubPullRequestSnapshot { - Number = pr.TryGetProperty("number", out JsonElement numberElement) && numberElement.TryGetInt32(out int number) ? number : 0, - AuthorAssociation = pr.TryGetProperty("author_association", out JsonElement associationElement) ? associationElement.GetString() : null + Number = pr.TryGetProperty("number", out JsonElement numberElement) && + numberElement.TryGetInt32(out int number) + ? number + : 0, + AuthorAssociation = pr.TryGetProperty("author_association", out JsonElement associationElement) + ? associationElement.GetString() + : null }) .Where(pr => pr.Number > 0) .ToArray(); @@ -696,9 +757,11 @@ private async Task GetWorkflowDataAsync(string repositoryApi int completedRuns = workflowRuns.Count(run => string.Equals(run.TryGetProperty("status", out JsonElement status) ? status.GetString() : null, "completed", StringComparison.OrdinalIgnoreCase)); + int failedRuns = workflowRuns.Count(run => string.Equals(run.TryGetProperty("conclusion", out JsonElement conclusion) ? conclusion.GetString() : null, "failure", StringComparison.OrdinalIgnoreCase)); + bool hasSuccess = workflowRuns.Any(run => string.Equals(run.TryGetProperty("conclusion", out JsonElement conclusion) ? conclusion.GetString() : null, "success", StringComparison.OrdinalIgnoreCase)); @@ -752,7 +815,8 @@ protectedElement.ValueKind is JsonValueKind.True or JsonValueKind.False } /// Reads CHANGELOG file and checks if it looks like a boilerplate. - private async Task GetChangelogDataAsync(string repositoryApiRoot, string defaultBranch, string[] rootFiles) + private async Task GetChangelogDataAsync(string repositoryApiRoot, string defaultBranch, + string[] rootFiles) { string? changelogFile = rootFiles.FirstOrDefault(IsChangelogFile); @@ -770,7 +834,8 @@ private async Task GetChangelogDataAsync(string repositoryA } /// Reads SECURITY.md and checks detail/disclosure quality. - private async Task GetSecurityPolicyDataAsync(string repositoryApiRoot, string defaultBranch, string[] rootFiles) + private async Task GetSecurityPolicyDataAsync(string repositoryApiRoot, string defaultBranch, + string[] rootFiles) { string? securityPolicyPath = rootFiles.FirstOrDefault(file => file.Equals("SECURITY.md", StringComparison.OrdinalIgnoreCase) || @@ -788,6 +853,7 @@ private async Task GetSecurityPolicyDataAsync(string r bool hasContact = normalized.Contains("security@") || normalized.Contains("contact") || normalized.Contains("report"); + bool hasPrivateChannel = normalized.Contains("private") || normalized.Contains("gpg") || normalized.Contains("pgp") || @@ -864,7 +930,9 @@ private async Task GetCommitHealthDataAsync(string repos .Select(date => (DateTimeOffset.UtcNow - date).TotalDays) .ToList(); - int recentMaintainerCount = lastActivityByMaintainer.Values.Count(date => date >= DateTimeOffset.UtcNow.AddMonths(-6)); + int recentMaintainerCount = + lastActivityByMaintainer.Values.Count(date => date >= DateTimeOffset.UtcNow.AddMonths(-6)); + return new GitHubCommitHealthData { RecentMaintainerCount = recentMaintainerCount, @@ -896,7 +964,8 @@ private async Task GetClosedBugIssuesAsync(string r .Where(issue => !issue.TryGetProperty("pull_request", out _)) .Select(issue => new GitHubClosedIssueSnapshot { - Number = issue.TryGetProperty("number", out JsonElement numberElement) && numberElement.TryGetInt32(out int number) + Number = issue.TryGetProperty("number", out JsonElement numberElement) && + numberElement.TryGetInt32(out int number) ? number : 0, ClosedAt = TryReadDate(issue, "closed_at") @@ -930,7 +999,8 @@ private async Task CountReopenedIssuesAsync(string repositoryApiRoot, GitHu return eventsDoc.RootElement.ValueKind == JsonValueKind.Array && eventsDoc.RootElement.EnumerateArray().Any(eventItem => - string.Equals(eventItem.TryGetProperty("event", out JsonElement eventElement) ? eventElement.GetString() : null, + string.Equals( + eventItem.TryGetProperty("event", out JsonElement eventElement) ? eventElement.GetString() : null, "reopened", StringComparison.OrdinalIgnoreCase)); } catch @@ -972,7 +1042,8 @@ private async Task CountReopenedIssuesAsync(string repositoryApiRoot, GitHu } /// Reads workflow YAML files and detects signals for testing, coverage, reproducibility, and dependency automation. - private async Task GetWorkflowFileSignalsAsync(string repositoryApiRoot, string defaultBranch, string[] rootFiles) + private async Task GetWorkflowFileSignalsAsync(string repositoryApiRoot, string defaultBranch, + string[] rootFiles) { try { @@ -990,7 +1061,9 @@ private async Task GetWorkflowFileSignalsAsync(string .Select(path => path!) .ToArray(); - string[] contents = await Task.WhenAll(paths.Select(path => TryGetFileContentAsync(repositoryApiRoot, path, defaultBranch))); + string[] contents = + await Task.WhenAll(paths.Select(path => TryGetFileContentAsync(repositoryApiRoot, path, defaultBranch))); + string combined = string.Join("\n", contents).ToLowerInvariant(); string dependabotConfig = await TryGetFileContentAsync(repositoryApiRoot, ".github/dependabot.yml", defaultBranch); @@ -1028,10 +1101,10 @@ private async Task GetWorkflowFileSignalsAsync(string combined.Contains("reproducible", StringComparison.OrdinalIgnoreCase) || combined.Contains("source-build", StringComparison.OrdinalIgnoreCase), HasDependencyUpdateAutomation = !string.IsNullOrWhiteSpace(dependabotConfig) || - combined.Contains("dependabot", StringComparison.OrdinalIgnoreCase) || - combined.Contains("renovate", StringComparison.OrdinalIgnoreCase) || - rootFiles.Contains("renovate.json", StringComparer.OrdinalIgnoreCase) || - rootFiles.Contains("renovate.json5", StringComparer.OrdinalIgnoreCase), + combined.Contains("dependabot", StringComparison.OrdinalIgnoreCase) || + combined.Contains("renovate", StringComparison.OrdinalIgnoreCase) || + rootFiles.Contains("renovate.json", StringComparer.OrdinalIgnoreCase) || + rootFiles.Contains("renovate.json5", StringComparer.OrdinalIgnoreCase), HasTestSignal = combined.Contains("dotnet test", StringComparison.OrdinalIgnoreCase) || combined.Contains("npm test", StringComparison.OrdinalIgnoreCase) || combined.Contains("pnpm test", StringComparison.OrdinalIgnoreCase) || @@ -1056,7 +1129,9 @@ private async Task GetReviewerLoginsAsync(string repositoryApiRoot, in { try { - using JsonDocument reviewsDoc = await GetJsonAsync($"{repositoryApiRoot}/pulls/{pullRequestNumber}/reviews?per_page=100"); + using JsonDocument reviewsDoc = + await GetJsonAsync($"{repositoryApiRoot}/pulls/{pullRequestNumber}/reviews?per_page=100"); + if (reviewsDoc.RootElement.ValueKind != JsonValueKind.Array) { return []; @@ -1084,6 +1159,7 @@ private async Task TryGetScorecardDataAsync(string owner, s { using HttpResponseMessage response = await HttpClient.GetAsync( $"https://api.securityscorecards.dev/projects/github.com/{owner}/{repo}"); + response.EnsureSuccessStatusCode(); using JsonDocument scorecardDoc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); @@ -1114,7 +1190,8 @@ private async Task TryGetScorecardDataAsync(string owner, s return new GitHubScorecardData { - Score = root.TryGetProperty("score", out JsonElement score) && score.TryGetDouble(out double value) ? value : null, + Score = + root.TryGetProperty("score", out JsonElement score) && score.TryGetDouble(out double value) ? value : null, HasBranchProtection = hasBranchProtection, BinaryArtifactsScore = binaryArtifactsScore }; @@ -1220,6 +1297,7 @@ private static bool IsCriticalLabel(JsonElement label) string name = label.TryGetProperty("name", out JsonElement nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty; + return name.Contains("critical", StringComparison.OrdinalIgnoreCase) || name.Contains("security", StringComparison.OrdinalIgnoreCase) || name.Contains("sev1", StringComparison.OrdinalIgnoreCase); @@ -1243,7 +1321,7 @@ private static bool IsCriticalLabel(JsonElement label) } /// Strips v/release prefixes and tries to parse the tag as a NuGetVersion. - private static bool TryParseReleaseVersion(string? tagName, out NuGet.Versioning.NuGetVersion? version) + private static bool TryParseReleaseVersion(string? tagName, out NuGetVersion? version) { version = null; if (string.IsNullOrWhiteSpace(tagName)) @@ -1262,12 +1340,12 @@ private static bool TryParseReleaseVersion(string? tagName, out NuGet.Versioning normalized = normalized[1..]; } - return NuGet.Versioning.NuGetVersion.TryParse(normalized, out version); + return NuGetVersion.TryParse(normalized, out version); } /// Computes the fraction of consecutive release transitions that were major-version bumps. internal static double? ComputeMajorReleaseRatio( - IReadOnlyList<(DateTimeOffset PublishedAt, NuGet.Versioning.NuGetVersion Version)> releases) + IReadOnlyList<(DateTimeOffset PublishedAt, NuGetVersion Version)> releases) { if (releases.Count < 2) { @@ -1355,12 +1433,14 @@ private static bool HasRepositoryOwnershipOrRenameChurn(string? declaredReposito Match match = Regex.Match(repositoryUrl, @"github\.com/(?[a-zA-Z0-9._-]+)/(?[a-zA-Z0-9._-]+)", RegexOptions.IgnoreCase); + if (!match.Success) { return null; } - return $"{match.Groups["owner"].Value}/{match.Groups["repo"].Value.TrimEnd('.').Replace(".git", string.Empty, StringComparison.OrdinalIgnoreCase)}"; + return + $"{match.Groups["owner"].Value}/{match.Groups["repo"].Value.TrimEnd('.').Replace(".git", string.Empty, StringComparison.OrdinalIgnoreCase)}"; } /// Reads and parses a date property from a JSON element. diff --git a/Src/PackageGuard.Core/InternalsVisibleTo.cs b/Src/PackageGuard.Core/InternalsVisibleTo.cs index ef92448..1317d75 100644 --- a/Src/PackageGuard.Core/InternalsVisibleTo.cs +++ b/Src/PackageGuard.Core/InternalsVisibleTo.cs @@ -1,4 +1,3 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("PackageGuard.Specs")] - diff --git a/Src/PackageGuard.Core/IssueHealthRiskFactor.cs b/Src/PackageGuard.Core/IssueHealthRiskFactor.cs index b388c30..672afc9 100644 --- a/Src/PackageGuard.Core/IssueHealthRiskFactor.cs +++ b/Src/PackageGuard.Core/IssueHealthRiskFactor.cs @@ -11,15 +11,27 @@ public RiskFactorContribution Evaluate(PackageInfo package) var risk = 0.0; var rationale = new List(); + risk += EvaluateOpenIssueRisk(package, rationale); + risk += EvaluateClosureRateRisk(package, rationale); + + return new RiskFactorContribution(risk, rationale.ToArray()); + } + + private static double EvaluateOpenIssueRisk(PackageInfo package, List rationale) + { + double risk = 0; + if (package.OpenBugIssueCount > 25) { risk += 1.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"High number of open bug issues ({package.OpenBugIssueCount})", 1.5)); + rationale.Add(RiskEvaluationHelpers.CreateRationale($"High number of open bug issues ({package.OpenBugIssueCount})", + 1.5)); } else if (package.OpenBugIssueCount > 10) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Elevated number of open bug issues ({package.OpenBugIssueCount})", 0.5)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale($"Elevated number of open bug issues ({package.OpenBugIssueCount})", 0.5)); } if (package.StaleCriticalBugIssueCount > 0) @@ -30,33 +42,6 @@ public RiskFactorContribution Evaluate(PackageInfo package) 1.5)); } - double? closureRate = GetBugClosureRate(package); - if (closureRate != null) - { - double bugClosureRate = closureRate.Value; - if (bugClosureRate < 0.35) - { - risk += 1.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Bug closure rate is low ({RiskEvaluationHelpers.FormatPercentage(bugClosureRate)})", 1.0)); - } - else if (bugClosureRate < 0.60) - { - risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Bug closure rate is moderate ({RiskEvaluationHelpers.FormatPercentage(bugClosureRate)})", 0.5)); - } - else - { - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Bug closure rate looks healthy ({RiskEvaluationHelpers.FormatPercentage(bugClosureRate)})", 0.0)); - } - } - - double? reopenRate = GetBugReopenRate(package); - if (reopenRate > 0.20) - { - risk += 0.75; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Bug reopen rate is elevated ({RiskEvaluationHelpers.FormatPercentage(reopenRate.Value)})", 0.75)); - } - if (package.MedianIssueResponseDays > 30) { risk += 1.0; @@ -67,7 +52,8 @@ public RiskFactorContribution Evaluate(PackageInfo package) else if (package.MedianIssueResponseDays != null) { double responseDays = package.MedianIssueResponseDays.Value; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Median issue response time looks healthy ({RiskEvaluationHelpers.FormatScore(responseDays)} days)", 0.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Median issue response time looks healthy ({RiskEvaluationHelpers.FormatScore(responseDays)} days)", 0.0)); } if (package.MedianCriticalIssueResponseDays > 7) @@ -102,7 +88,45 @@ public RiskFactorContribution Evaluate(PackageInfo package) 0.75)); } - return new RiskFactorContribution(risk, rationale.ToArray()); + return risk; + } + + private static double EvaluateClosureRateRisk(PackageInfo package, List rationale) + { + double risk = 0; + + double? closureRate = GetBugClosureRate(package); + if (closureRate != null) + { + double bugClosureRate = closureRate.Value; + if (bugClosureRate < 0.35) + { + risk += 1.0; + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Bug closure rate is low ({RiskEvaluationHelpers.FormatPercentage(bugClosureRate)})", 1.0)); + } + else if (bugClosureRate < 0.60) + { + risk += 0.5; + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Bug closure rate is moderate ({RiskEvaluationHelpers.FormatPercentage(bugClosureRate)})", 0.5)); + } + else + { + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Bug closure rate looks healthy ({RiskEvaluationHelpers.FormatPercentage(bugClosureRate)})", 0.0)); + } + } + + double? reopenRate = GetBugReopenRate(package); + if (reopenRate > 0.20) + { + risk += 0.75; + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Bug reopen rate is elevated ({RiskEvaluationHelpers.FormatPercentage(reopenRate.Value)})", 0.75)); + } + + return risk; } /// diff --git a/Src/PackageGuard.Core/LegalRiskEvaluator.cs b/Src/PackageGuard.Core/LegalRiskEvaluator.cs index 03b456f..b2b9666 100644 --- a/Src/PackageGuard.Core/LegalRiskEvaluator.cs +++ b/Src/PackageGuard.Core/LegalRiskEvaluator.cs @@ -19,7 +19,8 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) else if (package.License.Equals("Unknown", StringComparison.OrdinalIgnoreCase)) { risk += 6.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Non-standard or unrecognized license type ({package.License})", 6.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale($"Non-standard or unrecognized license type ({package.License})", + 6.0)); } else if (IsRestrictiveLicense(package.License)) { diff --git a/Src/PackageGuard.Core/LicenseFetcher.cs b/Src/PackageGuard.Core/LicenseFetcher.cs index be32253..60effac 100644 --- a/Src/PackageGuard.Core/LicenseFetcher.cs +++ b/Src/PackageGuard.Core/LicenseFetcher.cs @@ -1,4 +1,3 @@ -using System.Net.Http; using System.Text.Json; using Microsoft.Extensions.Logging; using PackageGuard.Core.CSharp.FetchingStrategies; @@ -14,16 +13,17 @@ public sealed class LicenseFetcher(ILogger logger, string? gitHubApiKey = null) /// Ordered list of license-fetching strategies tried in sequence until one succeeds. /// private readonly IReadOnlyList fetchers = - [ - new CorrectMisbehavingPackagesFetcher(), - new GitHubLicenseFetcher(gitHubApiKey), - new UrlLicenseFetcher(logger) - ]; + [ + new CorrectMisbehavingPackagesFetcher(), + new GitHubLicenseFetcher(gitHubApiKey), + new UrlLicenseFetcher(logger) + ]; /// /// Test-only constructor that accepts an explicit set of license fetcher strategies. /// - internal LicenseFetcher(ILogger logger, string? gitHubApiKey, IEnumerable fetchers) : this(logger, gitHubApiKey) + internal LicenseFetcher(ILogger logger, string? gitHubApiKey, IEnumerable fetchers) : this(logger, + gitHubApiKey) { this.fetchers = fetchers.ToArray(); } diff --git a/Src/PackageGuard.Core/LicenseUrlRiskEnricher.cs b/Src/PackageGuard.Core/LicenseUrlRiskEnricher.cs index 9be9043..58cc3db 100644 --- a/Src/PackageGuard.Core/LicenseUrlRiskEnricher.cs +++ b/Src/PackageGuard.Core/LicenseUrlRiskEnricher.cs @@ -42,6 +42,7 @@ public async Task EnrichAsync(PackageInfo package) { logger.LogDebug("Failed to validate license URL for {Name} {Version}: {Error}", package.Name, package.Version, ex.Message); + package.HasValidLicenseUrl = false; package.HasValidatedLicenseUrl = true; } diff --git a/Src/PackageGuard.Core/Npm/CommonFileDetector.cs b/Src/PackageGuard.Core/Npm/CommonFileDetector.cs index 9248210..7c1a89e 100644 --- a/Src/PackageGuard.Core/Npm/CommonFileDetector.cs +++ b/Src/PackageGuard.Core/Npm/CommonFileDetector.cs @@ -12,14 +12,14 @@ public bool Detect(string projectOrSolutionPath, AnalyzerSettings settings) { var mappings = new List<(string lockFile, NpmPackageManager manager)> { - ( "package-lock.json", NpmPackageManager.Npm ), - ( ".npmrc", NpmPackageManager.Npm ), - ( "pnpm-lock.json", NpmPackageManager.Pnpm ), - ( "pnpm-workspace.yml", NpmPackageManager.Pnpm ), - ( "yarn.lock", NpmPackageManager.Yarn ), - ( ".yarnrc.yml", NpmPackageManager.Yarn ), - ( ".yarnrc", NpmPackageManager.Yarn ), - ( "package.json", NpmPackageManager.Npm ), + ("package-lock.json", NpmPackageManager.Npm), + (".npmrc", NpmPackageManager.Npm), + ("pnpm-lock.json", NpmPackageManager.Pnpm), + ("pnpm-workspace.yml", NpmPackageManager.Pnpm), + ("yarn.lock", NpmPackageManager.Yarn), + (".yarnrc.yml", NpmPackageManager.Yarn), + (".yarnrc", NpmPackageManager.Yarn), + ("package.json", NpmPackageManager.Npm), }; ChainablePath path = projectOrSolutionPath.ToPath(); diff --git a/Src/PackageGuard.Core/Npm/NpmProjectAnalysisStrategy.cs b/Src/PackageGuard.Core/Npm/NpmProjectAnalysisStrategy.cs index 300be1f..d23ac7b 100644 --- a/Src/PackageGuard.Core/Npm/NpmProjectAnalysisStrategy.cs +++ b/Src/PackageGuard.Core/Npm/NpmProjectAnalysisStrategy.cs @@ -99,7 +99,8 @@ private PolicyViolation[] VerifyAgainstPolicy(PackageInfoCollection packages, Pr { if (!policy.AllowList.Allows(package) || policy.DenyList.Denies(package)) { - violations.Add(new PolicyViolation(package.Name, package.Version, package.License!, package.Projects.ToArray(), + violations.Add(new PolicyViolation(package.Name, package.Version, package.License!, + package.Projects.ToArray(), package.Source, package.SourceUrl)); } } diff --git a/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs b/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs index 845296f..d47b710 100644 --- a/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs +++ b/Src/PackageGuard.Core/Npm/NpmRegistryMetadataFetcher.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; -using PackageGuard.Core.Common; using NuGet.Versioning; +using PackageGuard.Core.Common; namespace PackageGuard.Core.Npm; @@ -54,133 +54,152 @@ public async Task FetchMetadataAsync(PackageInfo package) string jsonContent = await HttpClient.GetStringAsync(registryUrl); using JsonDocument doc = JsonDocument.Parse(jsonContent); JsonElement root = doc.RootElement; - JsonElement timeElement = root.TryGetProperty("time", out JsonElement parsedTimeElement) && - parsedTimeElement.ValueKind == JsonValueKind.Object - ? parsedTimeElement - : default; - JsonElement currentVersionMetadata = TryGetCurrentVersionMetadata(root, package.Version); + ParseVersionMetadata(package, root); + ParsePackageMetadata(package, root); - if (timeElement.ValueKind == JsonValueKind.Object && - timeElement.TryGetProperty(package.Version, out JsonElement publishedElement) && - DateTimeOffset.TryParse(publishedElement.GetString(), out DateTimeOffset publishedAt)) - { - package.PublishedAt = publishedAt; - } + await FetchDownloadCountAsync(package); + } + catch (HttpRequestException ex) + { + logger.LogWarning("Failed to fetch NPM package metadata for {Name} {Version}: {Error}", + package.Name, package.Version, ex.Message); + } + catch (JsonException ex) + { + logger.LogWarning("Failed to parse NPM package metadata for {Name} {Version}: {Error}", + package.Name, package.Version, ex.Message); + } + } - if (root.TryGetProperty("dist-tags", out JsonElement distTagsElement) && - distTagsElement.ValueKind == JsonValueKind.Object && - distTagsElement.TryGetProperty("latest", out JsonElement latestElement)) - { - string? latestStableVersion = latestElement.GetString(); - if (!string.IsNullOrWhiteSpace(latestStableVersion)) - { - package.LatestStableVersion = latestStableVersion; + /// Parses version-specific metadata: published date, latest stable version, and version lag. + private static void ParseVersionMetadata(PackageInfo package, JsonElement root) + { + JsonElement timeElement = root.TryGetProperty("time", out JsonElement parsedTimeElement) && + parsedTimeElement.ValueKind == JsonValueKind.Object + ? parsedTimeElement + : default; + + if (timeElement.ValueKind == JsonValueKind.Object && + timeElement.TryGetProperty(package.Version, out JsonElement publishedElement) && + DateTimeOffset.TryParse(publishedElement.GetString(), out DateTimeOffset publishedAt)) + { + package.PublishedAt = publishedAt; + } - if (timeElement.ValueKind == JsonValueKind.Object && - timeElement.TryGetProperty(latestStableVersion, out JsonElement latestPublishedElement) && - DateTimeOffset.TryParse(latestPublishedElement.GetString(), out DateTimeOffset latestStablePublishedAt)) - { - package.LatestStablePublishedAt = latestStablePublishedAt; - } + if (!root.TryGetProperty("dist-tags", out JsonElement distTagsElement) || + distTagsElement.ValueKind != JsonValueKind.Object || + !distTagsElement.TryGetProperty("latest", out JsonElement latestElement)) + { + return; + } - if (TryParseSemanticVersion(latestStableVersion, out NuGetVersion? latestVersion) && - TryParseSemanticVersion(package.Version, out NuGetVersion? currentVersion)) - { - package.IsMajorVersionBehindLatest = latestVersion is not null && - currentVersion is not null && - latestVersion.Major > currentVersion.Major; - package.IsMinorVersionBehindLatest = latestVersion is not null && - currentVersion is not null && - latestVersion.Major == currentVersion.Major && - latestVersion > currentVersion; - } + string? latestStableVersion = latestElement.GetString(); + if (string.IsNullOrWhiteSpace(latestStableVersion)) + { + return; + } - if (package is { PublishedAt: not null, LatestStablePublishedAt: not null } && - package.LatestStablePublishedAt.Value > package.PublishedAt.Value) - { - package.VersionUpdateLagDays = (package.LatestStablePublishedAt.Value - package.PublishedAt.Value).TotalDays; - } - } - } + package.LatestStableVersion = latestStableVersion; - // Extract license if not already present - if (package.License is null) - { - if (currentVersionMetadata.ValueKind == JsonValueKind.Object && - currentVersionMetadata.TryGetProperty("license", out JsonElement currentLicenseElement)) - { - package.License = currentLicenseElement.GetString(); - } - else if (root.TryGetProperty("license", out JsonElement licenseElement)) - { - package.License = licenseElement.GetString(); - } + if (timeElement.ValueKind == JsonValueKind.Object && + timeElement.TryGetProperty(latestStableVersion, out JsonElement latestPublishedElement) && + DateTimeOffset.TryParse(latestPublishedElement.GetString(), out DateTimeOffset latestStablePublishedAt)) + { + package.LatestStablePublishedAt = latestStablePublishedAt; + } - logger.LogDebug("Found license for {Name}: {License}", package.Name, package.License); - } + if (TryParseSemanticVersion(latestStableVersion, out NuGetVersion? latestVersion) && + TryParseSemanticVersion(package.Version, out NuGetVersion? currentVersion)) + { + package.IsMajorVersionBehindLatest = latestVersion is not null && + currentVersion is not null && + latestVersion.Major > currentVersion.Major; + + package.IsMinorVersionBehindLatest = latestVersion is not null && + currentVersion is not null && + latestVersion.Major == currentVersion.Major && + latestVersion > currentVersion; + } - // Extract repository URL if not already present - if (package.RepositoryUrl is null) - { - JsonElement repositoryElement = currentVersionMetadata.ValueKind == JsonValueKind.Object && - currentVersionMetadata.TryGetProperty("repository", out JsonElement versionRepositoryElement) - ? versionRepositoryElement - : root.TryGetProperty("repository", out JsonElement rootRepositoryElement) - ? rootRepositoryElement - : default; - - if (repositoryElement.ValueKind == JsonValueKind.String) - { - package.RepositoryUrl = repositoryElement.GetString(); - } - else if (repositoryElement.ValueKind == JsonValueKind.Object && - repositoryElement.TryGetProperty("url", out JsonElement urlElement)) - { - string? repoUrl = urlElement.GetString(); - if (repoUrl is not null) - { - // Clean up git+ prefix and .git suffix if present - package.RepositoryUrl = repoUrl - .Replace("git+", "") - .Replace("git://", "https://") - .TrimEnd('/', '.', 'g', 'i', 't'); - } - } + if (package is { PublishedAt: not null, LatestStablePublishedAt: not null } && + package.LatestStablePublishedAt.Value > package.PublishedAt.Value) + { + package.VersionUpdateLagDays = + (package.LatestStablePublishedAt.Value - package.PublishedAt.Value).TotalDays; + } + } - logger.LogDebug("Found repository URL for {Name}: {Url}", package.Name, package.RepositoryUrl); - } + /// Parses license, repository URL, deprecation status, and license URL from the registry response. + private void ParsePackageMetadata(PackageInfo package, JsonElement root) + { + JsonElement currentVersionMetadata = TryGetCurrentVersionMetadata(root, package.Version); + // Extract license if not already present + if (package.License is null) + { if (currentVersionMetadata.ValueKind == JsonValueKind.Object && - currentVersionMetadata.TryGetProperty("deprecated", out JsonElement deprecatedElement)) + currentVersionMetadata.TryGetProperty("license", out JsonElement currentLicenseElement)) + { + package.License = currentLicenseElement.GetString(); + } + else if (root.TryGetProperty("license", out JsonElement licenseElement)) { - package.IsDeprecated = !string.IsNullOrWhiteSpace(deprecatedElement.GetString()); + package.License = licenseElement.GetString(); } - // Extract license URL if available (some packages have this) - if (package.LicenseUrl is null) + logger.LogDebug("Found license for {Name}: {License}", package.Name, package.License); + } + + // Extract repository URL if not already present + if (package.RepositoryUrl is null) + { + JsonElement repositoryElement = currentVersionMetadata.ValueKind == JsonValueKind.Object && + currentVersionMetadata.TryGetProperty("repository", + out JsonElement versionRepositoryElement) + ? versionRepositoryElement + : root.TryGetProperty("repository", out JsonElement rootRepositoryElement) + ? rootRepositoryElement + : default; + + if (repositoryElement.ValueKind == JsonValueKind.String) + { + package.RepositoryUrl = repositoryElement.GetString(); + } + else if (repositoryElement.ValueKind == JsonValueKind.Object && + repositoryElement.TryGetProperty("url", out JsonElement urlElement)) { - // Try to construct a license URL from the repository - if (package.RepositoryUrl is not null && package.RepositoryUrl.Contains("github.com")) + string? repoUrl = urlElement.GetString(); + if (repoUrl is not null) { - // Construct a typical GitHub license URL - string cleanUrl = package.RepositoryUrl.TrimEnd('/'); - package.LicenseUrl = $"{cleanUrl}/blob/master/LICENSE"; - logger.LogDebug("Constructed license URL for {Name}: {Url}", package.Name, package.LicenseUrl); + // Clean up git+ prefix and .git suffix if present + package.RepositoryUrl = repoUrl + .Replace("git+", "") + .Replace("git://", "https://") + .TrimEnd('/', '.', 'g', 'i', 't'); } } - await FetchDownloadCountAsync(package); + logger.LogDebug("Found repository URL for {Name}: {Url}", package.Name, package.RepositoryUrl); } - catch (HttpRequestException ex) + + if (currentVersionMetadata.ValueKind == JsonValueKind.Object && + currentVersionMetadata.TryGetProperty("deprecated", out JsonElement deprecatedElement)) { - logger.LogWarning("Failed to fetch NPM package metadata for {Name} {Version}: {Error}", - package.Name, package.Version, ex.Message); + package.IsDeprecated = !string.IsNullOrWhiteSpace(deprecatedElement.GetString()); } - catch (JsonException ex) + + // Extract license URL if available (some packages have this) + if (package.LicenseUrl is null) { - logger.LogWarning("Failed to parse NPM package metadata for {Name} {Version}: {Error}", - package.Name, package.Version, ex.Message); + // Try to construct a license URL from the repository + if (package.RepositoryUrl is not null && package.RepositoryUrl.Contains("github.com")) + { + // Construct a typical GitHub license URL + string cleanUrl = package.RepositoryUrl.TrimEnd('/'); + package.LicenseUrl = $"{cleanUrl}/blob/master/LICENSE"; + logger.LogDebug("Constructed license URL for {Name}: {Url}", package.Name, package.LicenseUrl); + } } } @@ -224,7 +243,7 @@ private string GetRegistryUrl(PackageInfo package) int packageIndex = -1; string packageNameInUrl = package.Name.Replace("/", "%2f"); // Scoped packages may be URL-encoded - for (int i = 0; i < pathSegments.Length; i++) + for (int i = 0; i < pathSegments.Length; i++) { string decodedSegment = Uri.UnescapeDataString(pathSegments[i]); if (decodedSegment == package.Name || decodedSegment == packageNameInUrl) diff --git a/Src/PackageGuard.Core/Npm/YarnLockFileParser.cs b/Src/PackageGuard.Core/Npm/YarnLockFileParser.cs index 011d0f6..7838596 100644 --- a/Src/PackageGuard.Core/Npm/YarnLockFileParser.cs +++ b/Src/PackageGuard.Core/Npm/YarnLockFileParser.cs @@ -164,7 +164,8 @@ private Dictionary ParseYarnV2Lock(string content) if (!string.IsNullOrEmpty(version)) { // For Yarn v2, construct resolved URL from npm registry - string resolvedUrl = $"https://registry.yarnpkg.com/{packageName}/-/{packageName.Split('/').Last()}-{version}.tgz"; + string resolvedUrl = + $"https://registry.yarnpkg.com/{packageName}/-/{packageName.Split('/').Last()}-{version}.tgz"; packages[packageName] = new YarnPackageData { @@ -262,7 +263,9 @@ private Dictionary ParseYarnV1Lock(string content) { // Scoped package: @scope/package@version var match = Regex.Match(packageDeclaration, @"^(@[^/]+/[^@]+)@"); - packageName = match.Success ? match.Groups[1].Value : packageDeclaration.Split('@')[0] + "/" + packageDeclaration.Split('@')[1]; + packageName = match.Success + ? match.Groups[1].Value + : packageDeclaration.Split('@')[0] + "/" + packageDeclaration.Split('@')[1]; } else { diff --git a/Src/PackageGuard.Core/NuGetPackageSigningRiskEnricher.cs b/Src/PackageGuard.Core/NuGetPackageSigningRiskEnricher.cs index 0678b05..d56bd45 100644 --- a/Src/PackageGuard.Core/NuGetPackageSigningRiskEnricher.cs +++ b/Src/PackageGuard.Core/NuGetPackageSigningRiskEnricher.cs @@ -1,6 +1,6 @@ -using System.IO.Compression; -using System.Diagnostics; using System.ComponentModel; +using System.Diagnostics; +using System.IO.Compression; using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using NuGet.Configuration; @@ -27,8 +27,9 @@ internal sealed class NuGetPackageSigningRiskEnricher(ILogger logger, string? gl /// The path to the NuGet global packages folder used to locate .nupkg archives on disk. /// private readonly string globalPackagesFolder = globalPackagesFolder - ?? Environment.GetEnvironmentVariable("NUGET_PACKAGES") - ?? SettingsUtility.GetGlobalPackagesFolder(Settings.LoadDefaultSettings(Directory.GetCurrentDirectory())); + ?? Environment.GetEnvironmentVariable("NUGET_PACKAGES") + ?? SettingsUtility.GetGlobalPackagesFolder( + Settings.LoadDefaultSettings(Directory.GetCurrentDirectory())); /// /// Returns if signing risk data has already been populated for . @@ -125,7 +126,9 @@ private ArchiveRiskData ReadArchiveRiskData(string packagePath) try { using ZipArchive archive = ZipFile.OpenRead(packagePath); - bool isSigned = archive.Entries.Any(entry => entry.FullName.Equals(".signature.p7s", StringComparison.OrdinalIgnoreCase)); + bool isSigned = + archive.Entries.Any(entry => entry.FullName.Equals(".signature.p7s", StringComparison.OrdinalIgnoreCase)); + string[] targetFrameworks = ReadTargetFrameworks(archive); return new ArchiveRiskData @@ -133,7 +136,8 @@ private ArchiveRiskData ReadArchiveRiskData(string packagePath) IsSigned = isSigned, HasTrustedSignature = isSigned ? VerifyTrustedSignature(packagePath) : false, SupportedTargetFrameworks = targetFrameworks, - HasModernTargetFrameworkSupport = targetFrameworks.Length == 0 ? null : targetFrameworks.Any(IsModernTargetFramework), + HasModernTargetFrameworkSupport = + targetFrameworks.Length == 0 ? null : targetFrameworks.Any(IsModernTargetFramework), HasNativeBinaryAssets = archive.Entries.Any(IsNativeBinaryAsset) }; } diff --git a/Src/PackageGuard.Core/OsvRiskEnricher.cs b/Src/PackageGuard.Core/OsvRiskEnricher.cs index cd21985..667f6a0 100644 --- a/Src/PackageGuard.Core/OsvRiskEnricher.cs +++ b/Src/PackageGuard.Core/OsvRiskEnricher.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using System.Text.Json; + namespace PackageGuard.Core; /// @@ -75,7 +76,8 @@ private async Task QueryAsync(PackageInfo package) do { using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.osv.dev/v1/query"); - request.Content = new StringContent(CreateRequestBody(package, ecosystem, pageToken), Encoding.UTF8, "application/json"); + request.Content = + new StringContent(CreateRequestBody(package, ecosystem, pageToken), Encoding.UTF8, "application/json"); using HttpResponseMessage response = await HttpClient.SendAsync(request); response.EnsureSuccessStatusCode(); @@ -328,7 +330,9 @@ private static double ParseScore(string? score) } string[] parts = score.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return parts.Select(part => double.TryParse(part, NumberStyles.Number, CultureInfo.InvariantCulture, out double value) ? value : 0).FirstOrDefault(value => value > 0); + return parts.Select(part => + double.TryParse(part, NumberStyles.Number, CultureInfo.InvariantCulture, out double value) ? value : 0) + .FirstOrDefault(value => value > 0); } /// diff --git a/Src/PackageGuard.Core/PackageAdoptionRiskFactor.cs b/Src/PackageGuard.Core/PackageAdoptionRiskFactor.cs index 9660774..eeec7aa 100644 --- a/Src/PackageGuard.Core/PackageAdoptionRiskFactor.cs +++ b/Src/PackageGuard.Core/PackageAdoptionRiskFactor.cs @@ -12,20 +12,34 @@ public RiskFactorContribution Evaluate(PackageInfo package) var risk = 0.0; var rationale = new List(); + risk += EvaluateDownloadRisk(package, rationale); + risk += EvaluateVersionRisk(package, rationale); + risk += EvaluateEcosystemRisk(package, rationale); + + return new RiskFactorContribution(risk, rationale.ToArray()); + } + + private static double EvaluateDownloadRisk(PackageInfo package, List rationale) + { + double risk = 0; + if (package.DownloadCount is < 1000) { risk += 2.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Low package popularity ({package.DownloadCount} downloads)", 2.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale($"Low package popularity ({package.DownloadCount} downloads)", + 2.0)); } else if (package.DownloadCount is < 10000) { risk += 1.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Limited package popularity ({package.DownloadCount} downloads)", 1.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale($"Limited package popularity ({package.DownloadCount} downloads)", + 1.0)); } else if (package.DownloadCount != null) { long downloadCount = package.DownloadCount.Value; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Package popularity is established ({downloadCount} downloads)", 0.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale($"Package popularity is established ({downloadCount} downloads)", + 0.0)); } if (package.HasPreOneZeroDependencies) @@ -37,7 +51,8 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (package.StaleTransitiveDependencyCount is > 0) { risk += 0.25; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Stale transitive dependencies were detected ({package.StaleTransitiveDependencyCount})", 0.25)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Stale transitive dependencies were detected ({package.StaleTransitiveDependencyCount})", 0.25)); } if (package.AbandonedTransitiveDependencyCount is > 0) @@ -64,6 +79,13 @@ public RiskFactorContribution Evaluate(PackageInfo package) 0.5)); } + return risk; + } + + private static double EvaluateVersionRisk(PackageInfo package, List rationale) + { + double risk = 0; + if (package.IsDeprecated is true) { risk += 1.0; @@ -73,14 +95,40 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (package.IsMajorVersionBehindLatest) { risk += 1.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Current package version is behind latest stable ({package.LatestStableVersion})", 1.5)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale( + $"Current package version is behind latest stable ({package.LatestStableVersion})", 1.5)); } else if (package.IsMinorVersionBehindLatest) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Current package version is behind latest stable ({package.LatestStableVersion})", 0.5)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale( + $"Current package version is behind latest stable ({package.LatestStableVersion})", 0.5)); } + if (package.VersionUpdateLagDays is > 365) + { + risk += 1.0; + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"The current version trails the latest stable release by a long time ({RiskEvaluationHelpers.FormatScore(package.VersionUpdateLagDays.Value)} days)", + 1.0)); + } + else if (package.VersionUpdateLagDays is > 90) + { + risk += 0.5; + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"The current version trails the latest stable release ({RiskEvaluationHelpers.FormatScore(package.VersionUpdateLagDays.Value)} days)", + 0.5)); + } + + return risk; + } + + private static double EvaluateEcosystemRisk(PackageInfo package, List rationale) + { + double risk = 0; + if (package is { HasModernTargetFrameworkSupport: false, SupportedTargetFrameworks: [_, ..] }) { risk += 0.5; @@ -99,16 +147,19 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (openSsfScore < 5.0) { risk += 1.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"OpenSSF Scorecard score is low ({RiskEvaluationHelpers.FormatScore(openSsfScore)})", 1.5)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"OpenSSF Scorecard score is low ({RiskEvaluationHelpers.FormatScore(openSsfScore)})", 1.5)); } else if (openSsfScore < 7.0) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"OpenSSF Scorecard score is moderate ({RiskEvaluationHelpers.FormatScore(openSsfScore)})", 0.5)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"OpenSSF Scorecard score is moderate ({RiskEvaluationHelpers.FormatScore(openSsfScore)})", 0.5)); } else { - rationale.Add(RiskEvaluationHelpers.CreateRationale($"OpenSSF Scorecard score is strong ({RiskEvaluationHelpers.FormatScore(openSsfScore)})", 0.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"OpenSSF Scorecard score is strong ({RiskEvaluationHelpers.FormatScore(openSsfScore)})", 0.0)); } } @@ -146,21 +197,6 @@ public RiskFactorContribution Evaluate(PackageInfo package) rationale.Add(RiskEvaluationHelpers.CreateRationale("No reproducible-build signal was detected", 0.25)); } - if (package.VersionUpdateLagDays is > 365) - { - risk += 1.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale( - $"The current version trails the latest stable release by a long time ({RiskEvaluationHelpers.FormatScore(package.VersionUpdateLagDays.Value)} days)", - 1.0)); - } - else if (package.VersionUpdateLagDays is > 90) - { - risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale( - $"The current version trails the latest stable release ({RiskEvaluationHelpers.FormatScore(package.VersionUpdateLagDays.Value)} days)", - 0.5)); - } - - return new RiskFactorContribution(risk, rationale.ToArray()); + return risk; } } diff --git a/Src/PackageGuard.Core/PackageInfo.cs b/Src/PackageGuard.Core/PackageInfo.cs index d9cce78..24f2a37 100644 --- a/Src/PackageGuard.Core/PackageInfo.cs +++ b/Src/PackageGuard.Core/PackageInfo.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using JetBrains.Annotations; using MemoryPack; using NuGet.Versioning; @@ -53,8 +52,7 @@ public partial class PackageInfo public string[] Projects { get => projects.ToArray(); - [UsedImplicitly] - set => projects = new List(); + [UsedImplicitly] set => projects = new List(); } /// diff --git a/Src/PackageGuard.Core/PackageInfoCollection.cs b/Src/PackageGuard.Core/PackageInfoCollection.cs index 635109b..633772c 100644 --- a/Src/PackageGuard.Core/PackageInfoCollection.cs +++ b/Src/PackageGuard.Core/PackageInfoCollection.cs @@ -108,7 +108,10 @@ private async Task TryLoadCache(string cacheFilePath) { await using FileStream fileStream = new(cacheFilePath, FileMode.Open, FileAccess.Read); PackageInfo[]? cachedPackages = await MemoryPackSerializer.DeserializeAsync(fileStream); - if (cachedPackages is not null) cache = BuildCache(cachedPackages); + if (cachedPackages is not null) + { + cache = BuildCache(cachedPackages); + } logger.LogInformation("Successfully loaded the cache from {CacheFilePath}", cacheFilePath); } @@ -203,7 +206,9 @@ private PackageInfo GetOrAddCachedPackage(string packageKey, PackageInfo package /// private PackageInfo? FindCachedPackage(string name, string version, string[] sourceUrls) { - PackageInfo? package = cache.Values.FirstOrDefault(p => MatchesPackage(p, name, version, sourceUrls) && ShouldReuseCachedPackage(p)); + PackageInfo? package = + cache.Values.FirstOrDefault(p => MatchesPackage(p, name, version, sourceUrls) && ShouldReuseCachedPackage(p)); + if (package is not null) { packages[package.GetCollectionKey()] = package; @@ -218,8 +223,8 @@ private PackageInfo GetOrAddCachedPackage(string packageKey, PackageInfo package private static bool MatchesPackage(PackageInfo package, string name, string version, string[] sourceUrls) { return package.Name == name && - package.Version == version && - sourceUrls.Contains(package.SourceUrl, StringComparer.OrdinalIgnoreCase); + package.Version == version && + sourceUrls.Contains(package.SourceUrl, StringComparer.OrdinalIgnoreCase); } /// @@ -250,7 +255,10 @@ private static void MergePackageState(PackageInfo target, PackageInfo source) { MergePackageMetadata(target, source); MergeProjects(target, source); - if (source.IsUsed) target.MarkAsUsed(); + if (source.IsUsed) + { + target.MarkAsUsed(); + } } /// @@ -259,8 +267,15 @@ private static void MergePackageState(PackageInfo target, PackageInfo source) /// private static void MergePackageMetadata(PackageInfo target, PackageInfo source) { - if (string.IsNullOrWhiteSpace(target.Source) && !string.IsNullOrWhiteSpace(source.Source)) target.Source = source.Source; - if (string.IsNullOrWhiteSpace(target.SourceUrl) && !string.IsNullOrWhiteSpace(source.SourceUrl)) target.SourceUrl = source.SourceUrl; + if (string.IsNullOrWhiteSpace(target.Source) && !string.IsNullOrWhiteSpace(source.Source)) + { + target.Source = source.Source; + } + + if (string.IsNullOrWhiteSpace(target.SourceUrl) && !string.IsNullOrWhiteSpace(source.SourceUrl)) + { + target.SourceUrl = source.SourceUrl; + } target.RepositoryUrl ??= source.RepositoryUrl; target.License ??= source.License; diff --git a/Src/PackageGuard.Core/ParallelPackageRiskEnricher.cs b/Src/PackageGuard.Core/ParallelPackageRiskEnricher.cs index 5696475..3f422db 100644 --- a/Src/PackageGuard.Core/ParallelPackageRiskEnricher.cs +++ b/Src/PackageGuard.Core/ParallelPackageRiskEnricher.cs @@ -23,13 +23,8 @@ internal sealed class ParallelPackageRiskEnricher /// The logger used by the individual enrichers. /// An optional GitHub API key used by the GitHub repository enricher. public ParallelPackageRiskEnricher(ILogger logger, string? gitHubApiKey) - : this( - [ - new LicenseUrlRiskEnricher(logger), - new NuGetPackageSigningRiskEnricher(logger), - new OsvRiskEnricher(), - new GitHubRepositoryRiskEnricher(logger, gitHubApiKey) - ]) + : this(new LicenseUrlRiskEnricher(logger), new NuGetPackageSigningRiskEnricher(logger), new OsvRiskEnricher(), + new GitHubRepositoryRiskEnricher(logger, gitHubApiKey)) { } diff --git a/Src/PackageGuard.Core/PolicyViolation.cs b/Src/PackageGuard.Core/PolicyViolation.cs index 10c6a1a..4bad6a8 100644 --- a/Src/PackageGuard.Core/PolicyViolation.cs +++ b/Src/PackageGuard.Core/PolicyViolation.cs @@ -1,3 +1,9 @@ namespace PackageGuard.Core; -public record PolicyViolation(string PackageId, string Version, string License, string[] Projects, string FeedName, string FeedUrl); +public record PolicyViolation( + string PackageId, + string Version, + string License, + string[] Projects, + string FeedName, + string FeedUrl); diff --git a/Src/PackageGuard.Core/ProjectAnalyzer.cs b/Src/PackageGuard.Core/ProjectAnalyzer.cs index 84b9b4a..e87821d 100644 --- a/Src/PackageGuard.Core/ProjectAnalyzer.cs +++ b/Src/PackageGuard.Core/ProjectAnalyzer.cs @@ -19,7 +19,8 @@ public class ProjectAnalyzer(LicenseFetcher licenseFetcher, RiskEvaluator? riskE /// /// Analyzes the project at against the configured policies and returns any violations found. /// - public async Task ExecuteAnalysis(string projectPath, AnalyzerSettings settings, GetPolicyByProject getPolicyByProject) + public async Task ExecuteAnalysis(string projectPath, AnalyzerSettings settings, + GetPolicyByProject getPolicyByProject) { AnalysisResult result = await ExecuteAnalysisWithRisk(projectPath, settings, getPolicyByProject); return result.Violations; @@ -104,5 +105,4 @@ private async Task BuildRiskReport(AnalyzerSettings settings, PackageInfoCollect Logger.LogInformation("Risk scoring complete for {PackageCount} packages.", allPackages.Length); } - } diff --git a/Src/PackageGuard.Core/ReleaseHealthRiskFactor.cs b/Src/PackageGuard.Core/ReleaseHealthRiskFactor.cs index a64c183..097ef13 100644 --- a/Src/PackageGuard.Core/ReleaseHealthRiskFactor.cs +++ b/Src/PackageGuard.Core/ReleaseHealthRiskFactor.cs @@ -64,7 +64,8 @@ public RiskFactorContribution Evaluate(PackageInfo package) if (package.HasSemVerReleaseTags is false) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("Recent release tags do not consistently follow semantic versioning", 0.5)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale("Recent release tags do not consistently follow semantic versioning", 0.5)); } else if (package.HasSemVerReleaseTags is true) { diff --git a/Src/PackageGuard.Core/RiskDimensions.cs b/Src/PackageGuard.Core/RiskDimensions.cs index ecad86d..a16e1c5 100644 --- a/Src/PackageGuard.Core/RiskDimensions.cs +++ b/Src/PackageGuard.Core/RiskDimensions.cs @@ -41,5 +41,5 @@ public partial class RiskDimensions /// /// Gets the overall risk score calculated from individual dimensions. /// - public double OverallRisk => (LegalRisk * 0.20) + (SecurityRisk * 0.45) + (OperationalRisk * 0.35); + public double OverallRisk => LegalRisk * 0.20 + SecurityRisk * 0.45 + OperationalRisk * 0.35; } diff --git a/Src/PackageGuard.Core/SecurityRiskEvaluator.cs b/Src/PackageGuard.Core/SecurityRiskEvaluator.cs index 36baeaa..f63a957 100644 --- a/Src/PackageGuard.Core/SecurityRiskEvaluator.cs +++ b/Src/PackageGuard.Core/SecurityRiskEvaluator.cs @@ -21,6 +21,16 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) rationale.Add(RiskEvaluationHelpers.CreateRationale("Public repository available", 0.0)); } + risk += EvaluateVulnerabilityRisk(package, rationale); + risk += EvaluateTransitiveDependencyRisk(package, rationale); + risk += EvaluateSupplyChainRisk(package, rationale); + risk += EvaluateOwnershipRisk(package, rationale); + + return RiskEvaluationHelpers.CreateEvaluation(risk, rationale); + } + + private static double EvaluateVulnerabilityRisk(PackageInfo package, List rationale) + { double vulnerabilityRisk = 0; if (package.VulnerabilityCount > 0) { @@ -64,17 +74,24 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) else if (package.MedianVulnerabilityFixDays != null) { double fixDays = package.MedianVulnerabilityFixDays.Value; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Median vulnerability fix time looks reasonable ({RiskEvaluationHelpers.FormatScore(fixDays)} days)", 0.0)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Median vulnerability fix time looks reasonable ({RiskEvaluationHelpers.FormatScore(fixDays)} days)", 0.0)); } double cappedVulnerabilityRisk = Math.Min(6.0, vulnerabilityRisk); - risk += cappedVulnerabilityRisk; if (vulnerabilityRisk > cappedVulnerabilityRisk) { rationale.Add("Vulnerability contribution capped at +6.0"); } + return cappedVulnerabilityRisk; + } + + private static double EvaluateTransitiveDependencyRisk(PackageInfo package, List rationale) + { + double risk = 0; + if (package.DependencyDepth > 20) { risk += 2.5; @@ -87,7 +104,9 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) } else if (package.DependencyDepth > 0) { - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Dependency depth {package.DependencyDepth} stays below the risk threshold", 0.0)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale( + $"Dependency depth {package.DependencyDepth} stays below the risk threshold", 0.0)); } double transitiveRisk = Math.Min(1.5, package.TransitiveVulnerabilityCount * 0.5); @@ -134,6 +153,13 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) criticalTransitiveRisk)); } + return risk; + } + + private static double EvaluateSupplyChainRisk(PackageInfo package, List rationale) + { + double risk = 0; + if (package.IsPackageSigned is false) { risk += 0.5; @@ -165,7 +191,9 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) if (package.HasNativeBinaryAssets is true) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("Package contains native or binary assets that may increase supply-chain exposure", 0.5)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale( + "Package contains native or binary assets that may increase supply-chain exposure", 0.5)); } if (package.VerifiedCommitRatio is < 0.5) @@ -178,34 +206,18 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) else if (package.VerifiedCommitRatio != null) { double verifiedCommitRatio = package.VerifiedCommitRatio.Value; - rationale.Add(RiskEvaluationHelpers.CreateRationale($"Verified commit coverage looks healthy ({RiskEvaluationHelpers.FormatPercentage(verifiedCommitRatio)})", 0.0)); - } - - if (package.IsDeprecated is true) - { - risk += 0.75; - rationale.Add(RiskEvaluationHelpers.CreateRationale("The package version is marked as deprecated", 0.75)); + rationale.Add(RiskEvaluationHelpers.CreateRationale( + $"Verified commit coverage looks healthy ({RiskEvaluationHelpers.FormatPercentage(verifiedCommitRatio)})", 0.0)); } - if (package.OwnerCreatedAt != null && - package.OwnerCreatedAt.Value > DateTimeOffset.UtcNow.AddYears(-1)) - { - risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("Repository owner account is less than one year old", 0.5)); - } + risk += EvaluateAttestationRisk(package, rationale); - if (!package.OwnerIsOrganization && (package.RecentMaintainerCount ?? package.ContributorCount) is <= 1) - { - risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("Single maintainer on a non-organization account", 0.5)); - } + return risk; + } - if (package.PublishedAt != null && - package.PublishedAt.Value < DateTimeOffset.UtcNow.AddMonths(-24)) - { - risk += 1.0; - rationale.Add(RiskEvaluationHelpers.CreateRationale("Last published release is older than 24 months", 1.0)); - } + private static double EvaluateAttestationRisk(PackageInfo package, List rationale) + { + double risk = 0; if (package.HasDetailedSecurityPolicy is false) { @@ -222,7 +234,8 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) if (package.HasProvenanceAttestation is false) { risk += 0.5; - rationale.Add(RiskEvaluationHelpers.CreateRationale("No provenance or attestation workflow signal was detected", 0.5)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale("No provenance or attestation workflow signal was detected", 0.5)); } else if (package.HasProvenanceAttestation is true) { @@ -232,7 +245,8 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) if (package.HasReproducibleBuildSignal is false) { risk += 0.25; - rationale.Add(RiskEvaluationHelpers.CreateRationale("No reproducible-build or deterministic-build signal was detected", 0.25)); + rationale.Add( + RiskEvaluationHelpers.CreateRationale("No reproducible-build or deterministic-build signal was detected", 0.25)); } if (package.HasVerifiedReleaseSignature is false) @@ -245,6 +259,39 @@ public RiskDimensionEvaluation Evaluate(PackageInfo package) rationale.Add(RiskEvaluationHelpers.CreateRationale("Verified release signature signal was detected", 0.0)); } - return RiskEvaluationHelpers.CreateEvaluation(risk, rationale); + return risk; + } + + private static double EvaluateOwnershipRisk(PackageInfo package, List rationale) + { + double risk = 0; + + if (package.IsDeprecated is true) + { + risk += 0.75; + rationale.Add(RiskEvaluationHelpers.CreateRationale("The package version is marked as deprecated", 0.75)); + } + + if (package.OwnerCreatedAt != null && + package.OwnerCreatedAt.Value > DateTimeOffset.UtcNow.AddYears(-1)) + { + risk += 0.5; + rationale.Add(RiskEvaluationHelpers.CreateRationale("Repository owner account is less than one year old", 0.5)); + } + + if (!package.OwnerIsOrganization && (package.RecentMaintainerCount ?? package.ContributorCount) is <= 1) + { + risk += 0.5; + rationale.Add(RiskEvaluationHelpers.CreateRationale("Single maintainer on a non-organization account", 0.5)); + } + + if (package.PublishedAt != null && + package.PublishedAt.Value < DateTimeOffset.UtcNow.AddMonths(-24)) + { + risk += 1.0; + rationale.Add(RiskEvaluationHelpers.CreateRationale("Last published release is older than 24 months", 1.0)); + } + + return risk; } } diff --git a/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs b/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs index c580682..7ac79ec 100644 --- a/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs +++ b/Src/PackageGuard.Specs/AnalyzeCommandSettingsSpecs.cs @@ -1,5 +1,6 @@ using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; + namespace PackageGuard.Specs; [TestClass] diff --git a/Src/PackageGuard.Specs/AzureDevOpsCredentialSpecs.cs b/Src/PackageGuard.Specs/AzureDevOpsCredentialSpecs.cs index 9e06f45..d46979b 100644 --- a/Src/PackageGuard.Specs/AzureDevOpsCredentialSpecs.cs +++ b/Src/PackageGuard.Specs/AzureDevOpsCredentialSpecs.cs @@ -1,14 +1,8 @@ -using System.Threading.Tasks; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.VisualStudio.TestTools.UnitTesting; -using NuGet.Versioning; -using PackageGuard.Core; -using Pathy; namespace PackageGuard.Specs; [TestClass] public class AzureDevOpsCredentialSpecs { - } diff --git a/Src/PackageGuard.Specs/CSharp/DotNetLockFileLoaderSpecs.cs b/Src/PackageGuard.Specs/CSharp/DotNetLockFileLoaderSpecs.cs index 2ee479c..a2ba31d 100644 --- a/Src/PackageGuard.Specs/CSharp/DotNetLockFileLoaderSpecs.cs +++ b/Src/PackageGuard.Specs/CSharp/DotNetLockFileLoaderSpecs.cs @@ -2,7 +2,6 @@ using FluentAssertions; using Meziantou.Extensions.Logging.InMemory; using Microsoft.VisualStudio.TestTools.UnitTesting; -using PackageGuard.Core; using PackageGuard.Core.CSharp; using Pathy; diff --git a/Src/PackageGuard.Specs/CSharp/NuGetPackageAnalyzerSpecs.cs b/Src/PackageGuard.Specs/CSharp/NuGetPackageAnalyzerSpecs.cs index 88c45bf..1c77a83 100644 --- a/Src/PackageGuard.Specs/CSharp/NuGetPackageAnalyzerSpecs.cs +++ b/Src/PackageGuard.Specs/CSharp/NuGetPackageAnalyzerSpecs.cs @@ -59,7 +59,8 @@ public async Task Can_prefer_repository_metadata_over_project_url() var packages = new PackageInfoCollection(nullLogger); // Act - await analyzer.CollectPackageMetadata(ChainablePath.Current.Parent.Parent, "FluentAssertions", NuGetVersion.Parse("8.10.0"), + await analyzer.CollectPackageMetadata(ChainablePath.Current.Parent.Parent, "FluentAssertions", + NuGetVersion.Parse("8.10.0"), packages); // Assert diff --git a/Src/PackageGuard.Specs/Common/ConsoleTestLogger.cs b/Src/PackageGuard.Specs/Common/ConsoleTestLogger.cs index 5248d70..e398967 100644 --- a/Src/PackageGuard.Specs/Common/ConsoleTestLogger.cs +++ b/Src/PackageGuard.Specs/Common/ConsoleTestLogger.cs @@ -18,6 +18,7 @@ public static ILogger Create(string category, LogLevel minimumLevel = LogLevel.I o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; }); + builder.SetMinimumLevel(minimumLevel); }); diff --git a/Src/PackageGuard.Specs/DependencyHealthCountEnricherSpecs.cs b/Src/PackageGuard.Specs/DependencyHealthCountEnricherSpecs.cs index f05139d..41413da 100644 --- a/Src/PackageGuard.Specs/DependencyHealthCountEnricherSpecs.cs +++ b/Src/PackageGuard.Specs/DependencyHealthCountEnricherSpecs.cs @@ -16,8 +16,19 @@ internal class DependencyHealthCountEnricherSpecs internal async Task Counts_stale_direct_transitive_dependency() { string depKey = PackageInfo.CreatePackageKey("Stale.Lib", "1.0.0"); - var stale = new PackageInfo { Name = "Stale.Lib", Version = "1.0.0", PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30) }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + var stale = new PackageInfo + { + Name = "Stale.Lib", + Version = "1.0.0", + PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30) + }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -33,8 +44,19 @@ internal async Task Counts_stale_direct_transitive_dependency() internal async Task Does_not_count_fresh_dependency_as_stale() { string depKey = PackageInfo.CreatePackageKey("Fresh.Lib", "1.0.0"); - var fresh = new PackageInfo { Name = "Fresh.Lib", Version = "1.0.0", PublishedAt = DateTimeOffset.UtcNow.AddMonths(-2) }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + var fresh = new PackageInfo + { + Name = "Fresh.Lib", + Version = "1.0.0", + PublishedAt = DateTimeOffset.UtcNow.AddMonths(-2) + }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -58,7 +80,13 @@ internal async Task Counts_abandoned_dependency_when_stale_and_low_contributor_c PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30), ContributorCount = 1 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -82,7 +110,13 @@ internal async Task Counts_abandoned_dependency_when_stale_and_has_known_vulnera ContributorCount = 10, VulnerabilityCount = 2 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -107,7 +141,13 @@ internal async Task Does_not_count_stale_dependency_with_good_health_as_abandone VulnerabilityCount = 0, MaxVulnerabilitySeverity = 0 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -124,8 +164,19 @@ internal async Task Does_not_count_stale_dependency_with_good_health_as_abandone internal async Task Counts_deprecated_transitive_dependency() { string depKey = PackageInfo.CreatePackageKey("Deprecated.Lib", "1.0.0"); - var deprecated = new PackageInfo { Name = "Deprecated.Lib", Version = "1.0.0", IsDeprecated = true }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + var deprecated = new PackageInfo + { + Name = "Deprecated.Lib", + Version = "1.0.0", + IsDeprecated = true + }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -149,7 +200,13 @@ internal async Task Counts_unmaintained_critical_transitive_dependency() VulnerabilityCount = 1, MaxVulnerabilitySeverity = 9.0 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -173,7 +230,13 @@ internal async Task Does_not_count_stale_low_severity_as_unmaintained_critical() VulnerabilityCount = 1, MaxVulnerabilitySeverity = 3.0 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -197,6 +260,7 @@ internal async Task Counts_stale_dependencies_nested_transitively() Version = "1.0.0", PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30) }; + var direct = new PackageInfo { Name = "Direct.Lib", @@ -204,7 +268,13 @@ internal async Task Counts_stale_dependencies_nested_transitively() PublishedAt = DateTimeOffset.UtcNow.AddMonths(-2), DependencyKeys = [transitiveKey] }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [directKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [directKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -230,9 +300,27 @@ internal async Task Does_not_double_count_shared_stale_dependency() Version = "1.0.0", PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30) }; - var dep1 = new PackageInfo { Name = "Dep1.Lib", Version = "1.0.0", DependencyKeys = [sharedKey] }; - var dep2 = new PackageInfo { Name = "Dep2.Lib", Version = "1.0.0", DependencyKeys = [sharedKey] }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [dep1Key, dep2Key] }; + + var dep1 = new PackageInfo + { + Name = "Dep1.Lib", + Version = "1.0.0", + DependencyKeys = [sharedKey] + }; + + var dep2 = new PackageInfo + { + Name = "Dep2.Lib", + Version = "1.0.0", + DependencyKeys = [sharedKey] + }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [dep1Key, dep2Key] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -279,6 +367,7 @@ internal async Task Handles_circular_dependency_without_stack_overflow() PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30), DependencyKeys = [bKey] }; + var packageB = new PackageInfo { Name = "B.Lib", @@ -286,7 +375,13 @@ internal async Task Handles_circular_dependency_without_stack_overflow() PublishedAt = DateTimeOffset.UtcNow.AddMonths(-30), DependencyKeys = [aKey] }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [aKey] }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [aKey] + }; var enricher = new DependencyHealthCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) { diff --git a/Src/PackageGuard.Specs/Initializer.cs b/Src/PackageGuard.Specs/Initializer.cs index 1d75329..f9ff3c2 100644 --- a/Src/PackageGuard.Specs/Initializer.cs +++ b/Src/PackageGuard.Specs/Initializer.cs @@ -1,6 +1,8 @@ using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; + [assembly: Parallelize(Scope = ExecutionScope.ClassLevel)] + namespace PackageGuard.Specs; [TestClass] diff --git a/Src/PackageGuard.Specs/NuGetPackageSigningRiskEnricherSpecs.cs b/Src/PackageGuard.Specs/NuGetPackageSigningRiskEnricherSpecs.cs index f0bf169..b58e64f 100644 --- a/Src/PackageGuard.Specs/NuGetPackageSigningRiskEnricherSpecs.cs +++ b/Src/PackageGuard.Specs/NuGetPackageSigningRiskEnricherSpecs.cs @@ -37,7 +37,12 @@ internal async Task Should_mark_package_as_signed_when_signature_file_exists() { string packagePath = CreatePackageArchive("Test.Package", "1.0.0", signed: true); var enricher = new NuGetPackageSigningRiskEnricher(NullLogger.Instance, testDirectory); - var package = new PackageInfo { Name = "Test.Package", Version = "1.0.0", Source = "nuget" }; + var package = new PackageInfo + { + Name = "Test.Package", + Version = "1.0.0", + Source = "nuget" + }; await enricher.EnrichAsync(package); @@ -49,7 +54,12 @@ internal async Task Should_mark_package_as_signed_when_signature_file_exists() internal async Task Should_mark_package_as_unsigned_when_signature_file_is_missing() { var enricher = new NuGetPackageSigningRiskEnricher(NullLogger.Instance, testDirectory); - var package = new PackageInfo { Name = "Test.Package", Version = "1.0.0", Source = "nuget" }; + var package = new PackageInfo + { + Name = "Test.Package", + Version = "1.0.0", + Source = "nuget" + }; CreatePackageArchive("Test.Package", "1.0.0", signed: false); await enricher.EnrichAsync(package); @@ -61,7 +71,12 @@ internal async Task Should_mark_package_as_unsigned_when_signature_file_is_missi internal async Task Should_leave_signing_status_unknown_when_package_archive_is_missing() { var enricher = new NuGetPackageSigningRiskEnricher(NullLogger.Instance, testDirectory); - var package = new PackageInfo { Name = "Missing.Package", Version = "1.0.0", Source = "nuget" }; + var package = new PackageInfo + { + Name = "Missing.Package", + Version = "1.0.0", + Source = "nuget" + }; await enricher.EnrichAsync(package); @@ -72,7 +87,12 @@ internal async Task Should_leave_signing_status_unknown_when_package_archive_is_ internal async Task Should_detect_native_binary_assets() { var enricher = new NuGetPackageSigningRiskEnricher(NullLogger.Instance, testDirectory); - var package = new PackageInfo { Name = "Native.Package", Version = "1.0.0", Source = "nuget" }; + var package = new PackageInfo + { + Name = "Native.Package", + Version = "1.0.0", + Source = "nuget" + }; CreatePackageArchive("Native.Package", "1.0.0", signed: false, includeNativeBinary: true); await enricher.EnrichAsync(package); @@ -84,7 +104,12 @@ internal async Task Should_detect_native_binary_assets() internal async Task Should_treat_net10_target_frameworks_as_modern() { var enricher = new NuGetPackageSigningRiskEnricher(NullLogger.Instance, testDirectory); - var package = new PackageInfo { Name = "Modern.Package", Version = "1.0.0", Source = "nuget" }; + var package = new PackageInfo + { + Name = "Modern.Package", + Version = "1.0.0", + Source = "nuget" + }; CreatePackageArchive("Modern.Package", "1.0.0", signed: false, targetFramework: "net10.0"); await enricher.EnrichAsync(package); @@ -93,7 +118,8 @@ internal async Task Should_treat_net10_target_frameworks_as_modern() package.HasModernTargetFrameworkSupport.Should().BeTrue(); } - private string CreatePackageArchive(string packageId, string version, bool signed, bool includeNativeBinary = false, string targetFramework = "net9.0") + private string CreatePackageArchive(string packageId, string version, bool signed, bool includeNativeBinary = false, + string targetFramework = "net9.0") { string folder = Path.Combine(testDirectory, packageId.ToLowerInvariant(), version.ToLowerInvariant()); Directory.CreateDirectory(folder); diff --git a/Src/PackageGuard.Specs/PackageInfoCollectionSpecs.cs b/Src/PackageGuard.Specs/PackageInfoCollectionSpecs.cs index 4415847..7dd214b 100644 --- a/Src/PackageGuard.Specs/PackageInfoCollectionSpecs.cs +++ b/Src/PackageGuard.Specs/PackageInfoCollectionSpecs.cs @@ -271,7 +271,7 @@ public async Task Report_risk_ignores_stale_cached_packages() PackageInfo[] cachedPackages = [ - new PackageInfo + new() { Name = "Bogus", Version = "2.0.0", @@ -291,6 +291,7 @@ public async Task Report_risk_ignores_stale_cached_packages() ReportRisk = true, RiskCacheMaxAge = TimeSpan.FromHours(24) }); + await currentCollection.TryInitializeFromCache(cachePath); PackageInfo package = currentCollection.Find("Bogus", "2.0.0", [source]); @@ -322,6 +323,7 @@ public async Task Report_risk_can_force_refresh_of_cached_packages() ReportRisk = true, RefreshRiskCache = true }); + await currentCollection.TryInitializeFromCache(ChainablePath.Current / "cache.bin"); PackageInfo package = currentCollection.Find("Bogus", "2.0.0", [source]); @@ -342,6 +344,7 @@ public void Adding_the_same_package_twice_reuses_the_canonical_instance() Source = "nuget.org", SourceUrl = "https://nuget.org" }); + original.TrackAsUsedInProject("ProjectA"); // Act @@ -352,6 +355,7 @@ public void Adding_the_same_package_twice_reuses_the_canonical_instance() Source = "nuget.org", SourceUrl = "https://nuget.org" }); + duplicate.TrackAsUsedInProject("ProjectB"); // Assert diff --git a/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs b/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs index bf7bf0e..bca27db 100644 --- a/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs +++ b/Src/PackageGuard.Specs/ParallelPackageRiskEnricherSpecs.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Threading.Tasks; using FluentAssertions; @@ -145,7 +146,7 @@ public async Task Full_enrichment_pipeline_should_populate_all_network_risk_sign package.HasGitHubRiskData.Should().BeTrue(); } - private sealed class FakeRiskEnricher(System.Func hasCachedData) : IEnrichPackageRisk + private sealed class FakeRiskEnricher(Func hasCachedData) : IEnrichPackageRisk { public List EnrichedPackages { get; } = []; diff --git a/Src/PackageGuard.Specs/ProjectAnalyzerSpecs.cs b/Src/PackageGuard.Specs/ProjectAnalyzerSpecs.cs index 681f72f..bc42eed 100644 --- a/Src/PackageGuard.Specs/ProjectAnalyzerSpecs.cs +++ b/Src/PackageGuard.Specs/ProjectAnalyzerSpecs.cs @@ -330,7 +330,10 @@ public async Task Can_still_allow_a_package_that_violates_the_allowed_licenses() AllowList = new AllowList { Licenses = ["mit", "apache-2.0"], - Packages = [new PackageSelector("FluentAssertions"), new PackageSelector("Microsoft.Testing.Extensions.CodeCoverage")] + Packages = + [ + new PackageSelector("FluentAssertions"), new PackageSelector("Microsoft.Testing.Extensions.CodeCoverage") + ] } }); diff --git a/Src/PackageGuard.Specs/ReportRiskArgumentNormalizerSpecs.cs b/Src/PackageGuard.Specs/ReportRiskArgumentNormalizerSpecs.cs index 480402a..00d126a 100644 --- a/Src/PackageGuard.Specs/ReportRiskArgumentNormalizerSpecs.cs +++ b/Src/PackageGuard.Specs/ReportRiskArgumentNormalizerSpecs.cs @@ -18,7 +18,9 @@ public void Normalize_keeps_bare_report_risk_flag_without_extracting_a_path() [TestMethod] public void Normalize_extracts_a_path_following_report_risk() { - var result = ReportRiskArgumentNormalizer.Normalize(["Test.sln", "--report-risk", @"C:\temp\risk.html", "--ignore-violations"]); + var result = ReportRiskArgumentNormalizer.Normalize([ + "Test.sln", "--report-risk", @"C:\temp\risk.html", "--ignore-violations" + ]); result.Args.Should().Equal("Test.sln", "--report-risk", "--ignore-violations"); result.ReportRiskPath.Should().Be(@"C:\temp\risk.html"); @@ -27,7 +29,9 @@ public void Normalize_extracts_a_path_following_report_risk() [TestMethod] public void Normalize_extracts_an_inline_report_risk_path() { - var result = ReportRiskArgumentNormalizer.Normalize(["Test.sln", "--report-risk=C:\\temp\\risk.html", "--ignore-violations"]); + var result = ReportRiskArgumentNormalizer.Normalize([ + "Test.sln", "--report-risk=C:\\temp\\risk.html", "--ignore-violations" + ]); result.Args.Should().Equal("Test.sln", "--report-risk", "--ignore-violations"); result.ReportRiskPath.Should().Be(@"C:\temp\risk.html"); diff --git a/Src/PackageGuard.Specs/RiskEvaluatorSpecs.cs b/Src/PackageGuard.Specs/RiskEvaluatorSpecs.cs index 590298f..281cfbe 100644 --- a/Src/PackageGuard.Specs/RiskEvaluatorSpecs.cs +++ b/Src/PackageGuard.Specs/RiskEvaluatorSpecs.cs @@ -116,10 +116,10 @@ internal void Should_calculate_overall_risk_score() riskEvaluator.EvaluateRisk(package); // Assert - var expectedOverallRisk = (package.RiskDimensions.LegalRisk * 0.20) + - (package.RiskDimensions.SecurityRisk * 0.45) + - (package.RiskDimensions.OperationalRisk * 0.35); - + var expectedOverallRisk = package.RiskDimensions.LegalRisk * 0.20 + + package.RiskDimensions.SecurityRisk * 0.45 + + package.RiskDimensions.OperationalRisk * 0.35; + package.RiskDimensions.OverallRisk.Should().Be(expectedOverallRisk); package.RiskScore.Should().Be(expectedOverallRisk * 10); // Scaled to 0-100 } @@ -169,10 +169,15 @@ internal void Should_add_security_risk_for_vulnerabilities_and_dependency_depth( riskEvaluator.EvaluateRisk(package); package.RiskDimensions.SecurityRisk.Should().BeGreaterThan(7.0); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Known vulnerabilities found (1, max severity 8.0)")); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Median vulnerability fix time is slow")); + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Known vulnerabilities found (1, max severity 8.0)")); + + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Median vulnerability fix time is slow")); + package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Deep dependency chain (depth 11)")); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Vulnerable transitive dependencies (2)")); + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Vulnerable transitive dependencies (2)")); } [TestMethod] @@ -200,14 +205,23 @@ internal void Should_add_operational_risk_for_poor_repository_hygiene_and_low_po riskEvaluator.EvaluateRisk(package); package.RiskDimensions.OperationalRisk.Should().Be(10.0); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Last release is older than 24 months")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("README is missing or appears to be boilerplate")); + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Last release is older than 24 months")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("README is missing or appears to be boilerplate")); + package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("CONTRIBUTING guide is missing")); package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("SECURITY policy is missing")); package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Low contributor count (1)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("High number of open bug issues (30)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Low package popularity (500 downloads)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Dimension score capped at 10.0/10")); + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("High number of open bug issues (30)")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Low package popularity (500 downloads)")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Dimension score capped at 10.0/10")); } [TestMethod] @@ -260,19 +274,39 @@ internal void Should_add_new_security_and_operational_signals() riskEvaluator.EvaluateRisk(package); package.RiskDimensions.SecurityRisk.Should().BeGreaterThan(5.0); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("A security fix is available for a known vulnerability")); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Package is signed but trust verification failed")); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Verified publisher signal was not detected")); + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("A security fix is available for a known vulnerability")); + + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Package is signed but trust verification failed")); + + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Verified publisher signal was not detected")); package.RiskDimensions.OperationalRisk.Should().BeGreaterThan(9.0); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("CHANGELOG or release notes are missing or low quality")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Contribution concentration is high")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Recent CI workflow failures are elevated (4)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("No recent successful CI workflow run detected")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("No required status checks were detected")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Current package version is behind latest stable (2.0.0)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("OpenSSF Scorecard score is low (4.5)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Repository ownership or rename churn was detected")); + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("CHANGELOG or release notes are missing or low quality")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Contribution concentration is high")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Recent CI workflow failures are elevated (4)")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("No recent successful CI workflow run detected")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("No required status checks were detected")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Current package version is behind latest stable (2.0.0)")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("OpenSSF Scorecard score is low (4.5)")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Repository ownership or rename churn was detected")); } [TestMethod] @@ -311,10 +345,17 @@ internal void Should_add_operational_risk_for_issue_closure_and_workflow_quality package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Bug closure rate is low")); package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Bug reopen rate is elevated")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Critical issue response time is slow")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Maintainer response coverage is low")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("CI workflow failure rate is elevated")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("CI workflow history shows a potentially flaky failure pattern")); + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Critical issue response time is slow")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Maintainer response coverage is low")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("CI workflow failure rate is elevated")); + + package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => + item.Contains("CI workflow history shows a potentially flaky failure pattern")); } [TestMethod] @@ -363,20 +404,42 @@ internal void Should_add_easy_and_medium_quality_metric_signals() riskEvaluator.EvaluateRisk(package); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Unmaintained critical transitive dependencies were detected")); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("Verified commit coverage is limited")); - package.RiskDimensions.SecurityRiskRationale.Should().Contain(item => item.Contains("The package version is marked as deprecated")); + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Unmaintained critical transitive dependencies were detected")); + + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("Verified commit coverage is limited")); + + package.RiskDimensions.SecurityRiskRationale.Should() + .Contain(item => item.Contains("The package version is marked as deprecated")); package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Mean release interval is long")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Recent release tags do not consistently follow semantic versioning")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("A high share of semver release transitions were major-version jumps (50 %)")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Median maintainer inactivity is elevated")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Issue triage within 7 days is low")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("No dependency update automation signal was detected")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("No explicit test execution signal was detected")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Deprecated transitive dependencies were detected")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("Reviewer diversity looks limited")); - package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => item.Contains("The current version trails the latest stable release by a long time")); + package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => + item.Contains("Recent release tags do not consistently follow semantic versioning")); + + package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => + item.Contains("A high share of semver release transitions were major-version jumps (50 %)")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Median maintainer inactivity is elevated")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Issue triage within 7 days is low")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("No dependency update automation signal was detected")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("No explicit test execution signal was detected")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Deprecated transitive dependencies were detected")); + + package.RiskDimensions.OperationalRiskRationale.Should() + .Contain(item => item.Contains("Reviewer diversity looks limited")); + + package.RiskDimensions.OperationalRiskRationale.Should().Contain(item => + item.Contains("The current version trails the latest stable release by a long time")); } [TestMethod] @@ -427,6 +490,7 @@ internal void Should_treat_release_notes_as_a_valid_changelog_replacement() package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("CHANGELOG or release notes are present")); + package.RiskDimensions.OperationalRiskRationale.Should() .NotContain(item => item.Contains("CHANGELOG or release notes are missing or low quality")); } @@ -515,6 +579,7 @@ internal void Should_apply_higher_security_risk_for_dependency_chain_deeper_than deepPackage.RiskDimensions.SecurityRisk.Should() .BeGreaterThan(shallowPackage.RiskDimensions.SecurityRisk); + deepPackage.RiskDimensions.SecurityRiskRationale.Should() .Contain(item => item.Contains("Deep dependency chain (depth 25)")); } @@ -604,6 +669,7 @@ internal void Should_add_security_risk_when_coordinated_disclosure_is_absent() packageWithoutDisclosure.RiskDimensions.SecurityRisk.Should() .BeGreaterThan(packageWithDisclosure.RiskDimensions.SecurityRisk); + packageWithoutDisclosure.RiskDimensions.SecurityRiskRationale.Should() .Contain(item => item.Contains("No coordinated disclosure signal was detected")); } @@ -680,6 +746,7 @@ internal void Should_add_operational_risk_for_release_older_than_12_but_not_24_m package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("Last release is older than 12 months")); + package.RiskDimensions.OperationalRiskRationale.Should() .NotContain(item => item.Contains("Last release is older than 24 months")); } @@ -707,6 +774,7 @@ internal void Should_add_operational_risk_for_elevated_but_not_long_mean_release package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("Mean release interval is elevated")); + package.RiskDimensions.OperationalRiskRationale.Should() .NotContain(item => item.Contains("Mean release interval is long")); } @@ -736,8 +804,10 @@ internal void Should_add_operational_risk_for_stale_readme_and_changelog() package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("README has not been refreshed recently")); + package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("SECURITY policy lacks concrete response instructions")); + package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("CHANGELOG has not been refreshed recently")); } @@ -817,6 +887,7 @@ internal void Should_add_operational_risk_for_version_update_lag_between_90_and_ package.RiskDimensions.OperationalRiskRationale.Should() .Contain(item => item.Contains("The current version trails the latest stable release (120.0 days)")); + package.RiskDimensions.OperationalRiskRationale.Should() .NotContain(item => item.Contains("by a long time")); } diff --git a/Src/PackageGuard.Specs/RiskReportWriterSpecs.cs b/Src/PackageGuard.Specs/RiskReportWriterSpecs.cs index 44dc1ca..31d42c8 100644 --- a/Src/PackageGuard.Specs/RiskReportWriterSpecs.cs +++ b/Src/PackageGuard.Specs/RiskReportWriterSpecs.cs @@ -22,7 +22,9 @@ public void SetUp() { reportDirectory = Path.Combine(Path.GetTempPath(), "PackageGuard-ReportSpecs", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(reportDirectory); - string existingReportDirectory = Environment.GetEnvironmentVariable(RiskHtmlReportWriter.ReportDirectoryEnvironmentVariable); + string existingReportDirectory = + Environment.GetEnvironmentVariable(RiskHtmlReportWriter.ReportDirectoryEnvironmentVariable); + hadOriginalReportDirectory = existingReportDirectory is not null; originalReportDirectory = existingReportDirectory ?? ""; Environment.SetEnvironmentVariable(RiskHtmlReportWriter.ReportDirectoryEnvironmentVariable, reportDirectory); @@ -84,6 +86,7 @@ public async Task Should_write_companion_html_and_sarif_reports() ] } }; + package.TrackAsUsedInProject(@"src\Contoso.App\Contoso.App.csproj"); package.TrackAsUsedInProject(@"frontend\package.json"); @@ -106,12 +109,30 @@ public async Task Should_write_companion_html_and_sarif_reports() html.Should().Contain(@"frontend\package.json"); html.Should().Contain("Used by:"); html.Should().NotContain("Used by"); - html.Should().Contain("License URL (+0.0)"); - html.Should().Contain("Public repository available (+0.0)"); - html.Should().Contain("OpenSSF Scorecard score is low (4.0) (+1.5)"); - html.Should().Contain("README looks present and non-default (+0.0)"); - html.Should().Contain("CONTRIBUTING guide is present (+0.0)"); - html.Should().Contain("CHANGELOG or release notes are present (+0.0)"); + html.Should() + .Contain( + "License URL (+0.0)"); + + html.Should() + .Contain( + "Public repository available (+0.0)"); + + html.Should() + .Contain( + "OpenSSF Scorecard score is low (4.0) (+1.5)"); + + html.Should() + .Contain( + "README looks present and non-default (+0.0)"); + + html.Should() + .Contain( + "CONTRIBUTING guide is present (+0.0)"); + + html.Should() + .Contain( + "CHANGELOG or release notes are present (+0.0)"); + html.Should().NotContain("Relevant links"); html.Should().NotContain("License URL:"); html.Should().NotContain("Repository:"); @@ -122,7 +143,9 @@ public async Task Should_write_companion_html_and_sarif_reports() JsonElement result = sarif.RootElement.GetProperty("runs")[0].GetProperty("results")[0]; result.GetProperty("ruleId").GetString().Should().Be("packageguard/risk-medium"); result.GetProperty("message").GetProperty("text").GetString().Should().Contain("Contoso.Security 2.4.0 scored 47.5/100"); - result.GetProperty("message").GetProperty("text").GetString().Should().Contain(@"Used by: frontend\package.json, src\Contoso.App\Contoso.App.csproj."); + result.GetProperty("message").GetProperty("text").GetString().Should() + .Contain(@"Used by: frontend\package.json, src\Contoso.App\Contoso.App.csproj."); + result.GetProperty("properties").GetProperty("usedBy")[0].GetString().Should().Be(@"frontend\package.json"); result.GetProperty("properties").GetProperty("usedBy")[1].GetString().Should().Be(@"src\Contoso.App\Contoso.App.csproj"); result.GetProperty("locations")[0] @@ -172,7 +195,14 @@ public async Task Should_write_generated_report_names_inside_explicit_directory( RiskReportPaths reportPaths = await RiskHtmlReportWriter.WriteAsync( Path.Combine(reportDirectory, "PackageGuard.sln"), - [new PackageInfo { Name = "Contoso.Security", Version = "2.4.0", RiskDimensions = new RiskDimensions() }], + [ + new PackageInfo + { + Name = "Contoso.Security", + Version = "2.4.0", + RiskDimensions = new RiskDimensions() + } + ], explicitDirectory); Directory.Exists(explicitDirectory).Should().BeTrue(); @@ -194,7 +224,14 @@ public async Task Should_use_explicit_report_file_name_and_overwrite_existing_re RiskReportPaths reportPaths = await RiskHtmlReportWriter.WriteAsync( Path.Combine(reportDirectory, "PackageGuard.sln"), - [new PackageInfo { Name = "Contoso.Security", Version = "2.4.0", RiskDimensions = new RiskDimensions() }], + [ + new PackageInfo + { + Name = "Contoso.Security", + Version = "2.4.0", + RiskDimensions = new RiskDimensions() + } + ], explicitHtmlPath); reportPaths.HtmlPath.Should().Be(explicitHtmlPath); diff --git a/Src/PackageGuard.Specs/TransitiveVulnerabilityCountEnricherSpecs.cs b/Src/PackageGuard.Specs/TransitiveVulnerabilityCountEnricherSpecs.cs index 127e79a..ed75318 100644 --- a/Src/PackageGuard.Specs/TransitiveVulnerabilityCountEnricherSpecs.cs +++ b/Src/PackageGuard.Specs/TransitiveVulnerabilityCountEnricherSpecs.cs @@ -16,13 +16,25 @@ internal class TransitiveVulnerabilityCountEnricherSpecs internal async Task Counts_directly_vulnerable_transitive_dependency() { string depKey = PackageInfo.CreatePackageKey("Vuln.Lib", "1.0.0"); - var vulnerable = new PackageInfo { Name = "Vuln.Lib", Version = "1.0.0", VulnerabilityCount = 2 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + var vulnerable = new PackageInfo + { + Name = "Vuln.Lib", + Version = "1.0.0", + VulnerabilityCount = 2 + }; - var enricher = new TransitiveVulnerabilityCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) + var root = new PackageInfo { - [depKey] = vulnerable - }); + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; + + var enricher = new TransitiveVulnerabilityCountEnricher( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [depKey] = vulnerable + }); await enricher.EnrichAsync(root); @@ -33,13 +45,25 @@ internal async Task Counts_directly_vulnerable_transitive_dependency() internal async Task Does_not_count_dependency_with_zero_vulnerabilities() { string depKey = PackageInfo.CreatePackageKey("Clean.Lib", "1.0.0"); - var clean = new PackageInfo { Name = "Clean.Lib", Version = "1.0.0", VulnerabilityCount = 0 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [depKey] }; + var clean = new PackageInfo + { + Name = "Clean.Lib", + Version = "1.0.0", + VulnerabilityCount = 0 + }; - var enricher = new TransitiveVulnerabilityCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) + var root = new PackageInfo { - [depKey] = clean - }); + Name = "Root", + Version = "1.0.0", + DependencyKeys = [depKey] + }; + + var enricher = new TransitiveVulnerabilityCountEnricher( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [depKey] = clean + }); await enricher.EnrichAsync(root); @@ -52,7 +76,13 @@ internal async Task Counts_vulnerable_dependency_nested_transitively() string directKey = PackageInfo.CreatePackageKey("Direct.Lib", "1.0.0"); string transitiveKey = PackageInfo.CreatePackageKey("Transitive.Vuln.Lib", "1.0.0"); - var transitive = new PackageInfo { Name = "Transitive.Vuln.Lib", Version = "1.0.0", VulnerabilityCount = 1 }; + var transitive = new PackageInfo + { + Name = "Transitive.Vuln.Lib", + Version = "1.0.0", + VulnerabilityCount = 1 + }; + var direct = new PackageInfo { Name = "Direct.Lib", @@ -60,13 +90,20 @@ internal async Task Counts_vulnerable_dependency_nested_transitively() VulnerabilityCount = 0, DependencyKeys = [transitiveKey] }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [directKey] }; - var enricher = new TransitiveVulnerabilityCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) + var root = new PackageInfo { - [directKey] = direct, - [transitiveKey] = transitive - }); + Name = "Root", + Version = "1.0.0", + DependencyKeys = [directKey] + }; + + var enricher = new TransitiveVulnerabilityCountEnricher( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [directKey] = direct, + [transitiveKey] = transitive + }); await enricher.EnrichAsync(root); @@ -78,15 +115,33 @@ internal async Task Counts_multiple_distinct_vulnerable_transitive_dependencies( { string dep1Key = PackageInfo.CreatePackageKey("Vuln1.Lib", "1.0.0"); string dep2Key = PackageInfo.CreatePackageKey("Vuln2.Lib", "1.0.0"); - var vuln1 = new PackageInfo { Name = "Vuln1.Lib", Version = "1.0.0", VulnerabilityCount = 3 }; - var vuln2 = new PackageInfo { Name = "Vuln2.Lib", Version = "1.0.0", VulnerabilityCount = 1 }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [dep1Key, dep2Key] }; + var vuln1 = new PackageInfo + { + Name = "Vuln1.Lib", + Version = "1.0.0", + VulnerabilityCount = 3 + }; - var enricher = new TransitiveVulnerabilityCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) + var vuln2 = new PackageInfo { - [dep1Key] = vuln1, - [dep2Key] = vuln2 - }); + Name = "Vuln2.Lib", + Version = "1.0.0", + VulnerabilityCount = 1 + }; + + var root = new PackageInfo + { + Name = "Root", + Version = "1.0.0", + DependencyKeys = [dep1Key, dep2Key] + }; + + var enricher = new TransitiveVulnerabilityCountEnricher( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [dep1Key] = vuln1, + [dep2Key] = vuln2 + }); await enricher.EnrichAsync(root); @@ -100,17 +155,41 @@ internal async Task Does_not_double_count_shared_vulnerable_dependency() string dep1Key = PackageInfo.CreatePackageKey("Dep1.Lib", "1.0.0"); string dep2Key = PackageInfo.CreatePackageKey("Dep2.Lib", "1.0.0"); - var shared = new PackageInfo { Name = "Shared.Vuln.Lib", Version = "1.0.0", VulnerabilityCount = 1 }; - var dep1 = new PackageInfo { Name = "Dep1.Lib", Version = "1.0.0", DependencyKeys = [sharedKey] }; - var dep2 = new PackageInfo { Name = "Dep2.Lib", Version = "1.0.0", DependencyKeys = [sharedKey] }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [dep1Key, dep2Key] }; + var shared = new PackageInfo + { + Name = "Shared.Vuln.Lib", + Version = "1.0.0", + VulnerabilityCount = 1 + }; + + var dep1 = new PackageInfo + { + Name = "Dep1.Lib", + Version = "1.0.0", + DependencyKeys = [sharedKey] + }; + + var dep2 = new PackageInfo + { + Name = "Dep2.Lib", + Version = "1.0.0", + DependencyKeys = [sharedKey] + }; - var enricher = new TransitiveVulnerabilityCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) + var root = new PackageInfo { - [dep1Key] = dep1, - [dep2Key] = dep2, - [sharedKey] = shared - }); + Name = "Root", + Version = "1.0.0", + DependencyKeys = [dep1Key, dep2Key] + }; + + var enricher = new TransitiveVulnerabilityCountEnricher( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [dep1Key] = dep1, + [dep2Key] = dep2, + [sharedKey] = shared + }); await enricher.EnrichAsync(root); @@ -140,15 +219,35 @@ internal async Task Handles_circular_dependency_without_stack_overflow() string aKey = PackageInfo.CreatePackageKey("A.Lib", "1.0.0"); string bKey = PackageInfo.CreatePackageKey("B.Lib", "1.0.0"); - var packageA = new PackageInfo { Name = "A.Lib", Version = "1.0.0", VulnerabilityCount = 1, DependencyKeys = [bKey] }; - var packageB = new PackageInfo { Name = "B.Lib", Version = "1.0.0", VulnerabilityCount = 1, DependencyKeys = [aKey] }; - var root = new PackageInfo { Name = "Root", Version = "1.0.0", DependencyKeys = [aKey] }; + var packageA = new PackageInfo + { + Name = "A.Lib", + Version = "1.0.0", + VulnerabilityCount = 1, + DependencyKeys = [bKey] + }; + + var packageB = new PackageInfo + { + Name = "B.Lib", + Version = "1.0.0", + VulnerabilityCount = 1, + DependencyKeys = [aKey] + }; - var enricher = new TransitiveVulnerabilityCountEnricher(new Dictionary(StringComparer.OrdinalIgnoreCase) + var root = new PackageInfo { - [aKey] = packageA, - [bKey] = packageB - }); + Name = "Root", + Version = "1.0.0", + DependencyKeys = [aKey] + }; + + var enricher = new TransitiveVulnerabilityCountEnricher( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [aKey] = packageA, + [bKey] = packageB + }); var act = async () => await enricher.EnrichAsync(root); await act.Should().NotThrowAsync(); diff --git a/Src/PackageGuard/AnalyzeCommand.cs b/Src/PackageGuard/AnalyzeCommand.cs index fd02749..b8fe8fe 100644 --- a/Src/PackageGuard/AnalyzeCommand.cs +++ b/Src/PackageGuard/AnalyzeCommand.cs @@ -1,5 +1,5 @@ -using System.Reflection; using System.Globalization; +using System.Reflection; using JetBrains.Annotations; using Microsoft.Extensions.Logging; using PackageGuard.Core; @@ -13,7 +13,8 @@ namespace PackageGuard; /// [UsedImplicitly] internal sealed class AnalyzeCommand(ILogger logger) : AsyncCommand -{ /// +{ + /// /// Exit code indicating the analysis completed with no policy violations. /// private const int SuccessExitCode = 0; @@ -26,75 +27,84 @@ internal sealed class AnalyzeCommand(ILogger logger) : AsyncCommand /// Runs the package analysis, reports any policy violations to the console, and writes risk reports when requested. /// - protected override async Task ExecuteAsync(CommandContext context, AnalyzeCommandSettings settings, CancellationToken _) + public override async Task ExecuteAsync(CommandContext context, AnalyzeCommandSettings settings, CancellationToken _) { - // Display PackageGuard version var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"; logger.LogHeader($"PackageGuard v{version}"); - var licenseFetcher = new LicenseFetcher(logger, settings.GitHubApiKey); - var riskEvaluator = new RiskEvaluator(logger); - var analyzer = new ProjectAnalyzer(licenseFetcher, riskEvaluator) - { - Logger = logger, - }; - + var analyzer = BuildAnalyzer(settings); var loader = new ConfigurationLoader(logger); - // Use hierarchical configuration discovery if using default config path and it doesn't exist GetPolicyByProject getPolicy = _ => loader.GetConfigurationFromConfigPath(settings.ConfigPath); if (settings.ConfigPath == AnalyzeCommandSettings.DefaultConfigFileName && !File.Exists(settings.ConfigPath)) { getPolicy = loader.GetEffectiveConfigurationForProject; } - PolicyViolation[] violations; - PackageInfo[] packages = []; AnalyzerSettings analyzerSettings = settings.ToCoreSettings(); + (PolicyViolation[] violations, PackageInfo[] packages) = + await RunAnalysisAsync(analyzer, settings, analyzerSettings, getPolicy); - if (settings.ReportRisk) - { - var result = await analyzer.ExecuteAnalysisWithRisk(settings.ProjectPath, analyzerSettings, getPolicy); - violations = result.Violations; - packages = result.Packages; - } - else + logger.LogHeader("Completing analysis"); + + if (settings.ReportRisk && packages.Length > 0) { - violations = await analyzer.ExecuteAnalysis(settings.ProjectPath, analyzerSettings, getPolicy); + await WriteRiskReportsAsync(settings, packages); } - logger.LogHeader("Completing analysis"); + return ReportViolations(violations, settings); + } - // Write risk reports before reporting violations so they are always generated when requested - if (settings.ReportRisk && packages.Length > 0) + private ProjectAnalyzer BuildAnalyzer(AnalyzeCommandSettings settings) + { + var licenseFetcher = new LicenseFetcher(logger, settings.GitHubApiKey); + var riskEvaluator = new RiskEvaluator(logger); + return new ProjectAnalyzer(licenseFetcher, riskEvaluator) { Logger = logger }; + } + + private static async Task<(PolicyViolation[] violations, PackageInfo[] packages)> RunAnalysisAsync( + ProjectAnalyzer analyzer, + AnalyzeCommandSettings settings, + AnalyzerSettings analyzerSettings, + GetPolicyByProject getPolicy) + { + if (settings.ReportRisk) { - logger.LogHeader("Writing risk reports"); - logger.LogInformation( - "Writing detailed HTML and SARIF risk reports for {PackageCount} packages.", - packages.Length); + var result = await analyzer.ExecuteAnalysisWithRisk(settings.ProjectPath, analyzerSettings, getPolicy); + return (result.Violations, result.Packages); + } - RiskReportPaths reportPaths = await RiskHtmlReportWriter.WriteAsync( - settings.ProjectPath, - packages, - settings.GetReportRiskPath()); + PolicyViolation[] violations = await analyzer.ExecuteAnalysis(settings.ProjectPath, analyzerSettings, getPolicy); + return (violations, []); + } - AnsiConsole.MarkupLine("[yellow1]Package Risk Summary:[/]"); - AnsiConsole.MarkupLine(""); + private async Task WriteRiskReportsAsync(AnalyzeCommandSettings settings, PackageInfo[] packages) + { + logger.LogHeader("Writing risk reports"); + logger.LogInformation("Writing detailed HTML and SARIF risk reports for {PackageCount} packages.", packages.Length); - foreach (var package in packages.OrderByDescending(p => p.RiskScore)) - { - var riskColor = GetRiskColor(package.RiskScore); - AnsiConsole.MarkupLine( - $"- {Markup.Escape(package.Name)} {Markup.Escape(package.Version)}: [{riskColor}]{FormatDecimal(package.RiskScore)}/100 ({GetRiskZone(package.RiskScore)})[/]"); - } + RiskReportPaths reportPaths = await RiskHtmlReportWriter.WriteAsync( + settings.ProjectPath, packages, settings.GetReportRiskPath()); - AnsiConsole.MarkupLine(""); - AnsiConsole.MarkupLine("Detailed risk reports:"); - AnsiConsole.MarkupLine($"HTML: [blue]{Markup.Escape(reportPaths.HtmlPath)}[/]"); - AnsiConsole.MarkupLine($"SARIF: [blue]{Markup.Escape(reportPaths.SarifPath)}[/]"); - AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("[yellow1]Package Risk Summary:[/]"); + AnsiConsole.MarkupLine(""); + + foreach (var package in packages.OrderByDescending(p => p.RiskScore)) + { + var riskColor = GetRiskColor(package.RiskScore); + AnsiConsole.MarkupLine( + $"- {Markup.Escape(package.Name)} {Markup.Escape(package.Version)}: [{riskColor}]{FormatDecimal(package.RiskScore)}/100 ({GetRiskZone(package.RiskScore)})[/]"); } + AnsiConsole.MarkupLine(""); + AnsiConsole.MarkupLine("Detailed risk reports:"); + AnsiConsole.MarkupLine($"HTML: [blue]{Markup.Escape(reportPaths.HtmlPath)}[/]"); + AnsiConsole.MarkupLine($"SARIF: [blue]{Markup.Escape(reportPaths.SarifPath)}[/]"); + AnsiConsole.MarkupLine(""); + } + + private int ReportViolations(PolicyViolation[] violations, AnalyzeCommandSettings settings) + { if (violations.Length > 0) { AnsiConsole.MarkupLine("[red1]Policy violations found:[/]"); @@ -123,7 +133,6 @@ protected override async Task ExecuteAsync(CommandContext context, AnalyzeC } AnsiConsole.MarkupLine("[green3_1]No policy violations found.[/]"); - return SuccessExitCode; } @@ -152,6 +161,7 @@ private static string GetRiskZone(double score) _ => "Low" }; } + /// /// Formats a double value to one decimal place using invariant culture. /// diff --git a/Src/PackageGuard/Program.cs b/Src/PackageGuard/Program.cs index 67a8d04..6eb3e68 100644 --- a/Src/PackageGuard/Program.cs +++ b/Src/PackageGuard/Program.cs @@ -6,6 +6,7 @@ using Serilog; using Spectre.Console.Cli; using Spectre.Console.Cli.Extensions.DependencyInjection; +using Vertical.SpectreLogger; using ILogger = Microsoft.Extensions.Logging.ILogger; var services = new ServiceCollection(); @@ -13,6 +14,13 @@ services.AddLogging(configure => configure .SetMinimumLevel(LogLevel.Debug) .AddSerilog() + .AddSpectreConsole(b => b + .ConfigureProfile(LogLevel.Trace, p => p.OutputTemplate = "[grey35]{Message}{NewLine}{Exception}[/]") + .ConfigureProfile(LogLevel.Debug, p => p.OutputTemplate = "[grey46]{Message}{NewLine}{Exception}[/]") + .ConfigureProfile(LogLevel.Information, p => p.OutputTemplate = "[grey85]{Message}{NewLine}{Exception}[/]") + .ConfigureProfile(LogLevel.Warning, p => p.OutputTemplate = "[gold1]{Message}{NewLine}{Exception}[/]") + .ConfigureProfile(LogLevel.Error, p => p.OutputTemplate = "[white on red1]{Message}{NewLine}{Exception}[/]") + .SetMinimumLevel(LogLevel.Debug)) ); services.AddSingleton(sp => sp @@ -25,7 +33,9 @@ app.Configure(c => c.CaseSensitivity(CaseSensitivity.None)); -string? previousReportRiskPath = Environment.GetEnvironmentVariable(AnalyzeCommandSettings.ReportRiskPathOverrideEnvironmentVariable); +string? previousReportRiskPath = + Environment.GetEnvironmentVariable(AnalyzeCommandSettings.ReportRiskPathOverrideEnvironmentVariable); + (string[] normalizedArgs, string? reportRiskPath) = ReportRiskArgumentNormalizer.Normalize(args); Environment.SetEnvironmentVariable(AnalyzeCommandSettings.ReportRiskPathOverrideEnvironmentVariable, reportRiskPath); diff --git a/Src/PackageGuard/RiskHtmlReportWriter.cs b/Src/PackageGuard/RiskHtmlReportWriter.cs index 68aaf19..e73f4a8 100644 --- a/Src/PackageGuard/RiskHtmlReportWriter.cs +++ b/Src/PackageGuard/RiskHtmlReportWriter.cs @@ -15,6 +15,7 @@ internal static class RiskHtmlReportWriter /// Environment variable that overrides the output directory for generated risk reports. /// internal const string ReportDirectoryEnvironmentVariable = "PACKAGEGUARD_REPORT_DIRECTORY"; + /// /// UTF-8 encoding without a byte-order mark, used when writing report files to disk. /// @@ -129,6 +130,7 @@ private static RiskReportPaths CreateGeneratedReportPaths(string projectPath, st string sanitizedProjectName = string.Concat(projectName.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch)); + string fileNamePrefix = $"{sanitizedProjectName}-risk-report-{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss}"; return new RiskReportPaths( Path.Combine(reportDirectory, $"{fileNamePrefix}.html"), @@ -153,6 +155,20 @@ private static string BuildHtml(string projectPath, PackageInfo[] packages) _ => "Low" }; + AppendHtmlHead(builder, projectPath); + AppendReportHeader(builder, new ReportHeaderContext( + projectPath, generatedAt, overallZone, packages.Length, lowRiskCount, mediumRiskCount, highRiskCount)); + AppendPackageSummaryTable(builder, packages); + AppendPackageDetailSections(builder, packages); + + builder.AppendLine(""); + builder.AppendLine(""); + builder.AppendLine(""); + return builder.ToString(); + } + + private static void AppendHtmlHead(StringBuilder builder, string projectPath) + { builder.AppendLine(""); builder.AppendLine(""); builder.AppendLine(""); @@ -160,65 +176,109 @@ private static string BuildHtml(string projectPath, PackageInfo[] packages) builder.AppendLine(" "); builder.AppendLine($" {Encode(Path.GetFileName(projectPath))} - PackageGuard Risk Report"); builder.AppendLine(" "); builder.AppendLine(""); builder.AppendLine(""); builder.AppendLine("
"); + } + + private sealed record ReportHeaderContext( + string ProjectPath, + string GeneratedAt, + string OverallZone, + int PackageCount, + int LowRiskCount, + int MediumRiskCount, + int HighRiskCount); + + private static void AppendReportHeader(StringBuilder builder, ReportHeaderContext ctx) + { + string projectPath = ctx.ProjectPath; + string generatedAt = ctx.GeneratedAt; + string overallZone = ctx.OverallZone; + int packageCount = ctx.PackageCount; + int lowRiskCount = ctx.LowRiskCount; + int mediumRiskCount = ctx.MediumRiskCount; + int highRiskCount = ctx.HighRiskCount; builder.AppendLine("
"); builder.AppendLine("

PackageGuard Risk Report

"); builder.AppendLine($"

Project: {Encode(projectPath)}

"); builder.AppendLine($"

Generated at {Encode(generatedAt)}

"); - builder.AppendLine("

Static, self-contained HTML. No scripts, no external assets, and safe to publish as a build artifact.

"); + builder.AppendLine( + "

Static, self-contained HTML. No scripts, no external assets, and safe to publish as a build artifact.

"); builder.AppendLine("
"); - builder.AppendLine($"

{packages.Length}

Packages analyzed

"); - builder.AppendLine($"

{BuildZonePill(overallZone)}

Overall report status

"); - builder.AppendLine($"

{BuildZonePill("Low", lowRiskCount.ToString(CultureInfo.InvariantCulture))}

Low-risk packages

"); - builder.AppendLine($"

{BuildZonePill("Medium", mediumRiskCount.ToString(CultureInfo.InvariantCulture))}

Medium-risk packages

"); - builder.AppendLine($"

{BuildZonePill("High", highRiskCount.ToString(CultureInfo.InvariantCulture))}

High-risk packages

"); + builder.AppendLine( + $"

{packageCount}

Packages analyzed

"); + builder.AppendLine( + $"

{BuildZonePill(overallZone)}

Overall report status

"); + builder.AppendLine( + $"

{BuildZonePill("Low", lowRiskCount.ToString(CultureInfo.InvariantCulture))}

Low-risk packages

"); + builder.AppendLine( + $"

{BuildZonePill("Medium", mediumRiskCount.ToString(CultureInfo.InvariantCulture))}

Medium-risk packages

"); + builder.AppendLine( + $"

{BuildZonePill("High", highRiskCount.ToString(CultureInfo.InvariantCulture))}

High-risk packages

"); builder.AppendLine("
"); builder.AppendLine("
"); builder.AppendLine("
"); builder.AppendLine("

Status Check Summary

"); - builder.AppendLine("

This section is intended to be readable as the first screen in Azure DevOps artifacts or from a linked GitHub status check.

"); + builder.AppendLine( + "

This section is intended to be readable as the first screen in Azure DevOps artifacts or from a linked GitHub status check.

"); builder.AppendLine(" "); builder.AppendLine(" "); builder.AppendLine(" "); - builder.AppendLine($" "); - builder.AppendLine($" "); - builder.AppendLine($" "); + builder.AppendLine( + $" "); + builder.AppendLine( + $" "); + builder.AppendLine( + $" "); builder.AppendLine(" "); builder.AppendLine("
SeverityCountMeaning
{BuildZonePill("Low")}{lowRiskCount.ToString(CultureInfo.InvariantCulture)}Package score below 30
{BuildZonePill("Medium")}{mediumRiskCount.ToString(CultureInfo.InvariantCulture)}Package score from 30 to 59.9
{BuildZonePill("High")}{highRiskCount.ToString(CultureInfo.InvariantCulture)}Package score 60 or above
{BuildZonePill("Low")}{lowRiskCount.ToString(CultureInfo.InvariantCulture)}Package score below 30
{BuildZonePill("Medium")}{mediumRiskCount.ToString(CultureInfo.InvariantCulture)}Package score from 30 to 59.9
{BuildZonePill("High")}{highRiskCount.ToString(CultureInfo.InvariantCulture)}Package score 60 or above
"); builder.AppendLine("
"); + } + + private static void AppendPackageSummaryTable(StringBuilder builder, PackageInfo[] packages) + { builder.AppendLine("
"); builder.AppendLine("

Package Summary

"); builder.AppendLine(" "); - builder.AppendLine(" "); + builder.AppendLine( + " "); builder.AppendLine(" "); foreach (PackageInfo package in packages) @@ -244,7 +304,10 @@ private static string BuildHtml(string projectPath, PackageInfo[] packages) builder.AppendLine(" "); builder.AppendLine("
PackageVersionOverall riskLegalSecurityOperational
PackageVersionOverall riskLegalSecurityOperational
"); builder.AppendLine("
"); + } + private static void AppendPackageDetailSections(StringBuilder builder, PackageInfo[] packages) + { foreach (PackageInfo package in packages) { string packageAnchor = CreatePackageAnchor(package); @@ -253,9 +316,12 @@ private static string BuildHtml(string projectPath, PackageInfo[] packages) builder.AppendLine($"

{BuildOverallScorePill(package.RiskScore)}

"); builder.AppendLine("

Back to report summary

"); builder.AppendLine("
"); - AppendDimension(builder, package, "Legal", package.RiskDimensions.LegalRisk, package.RiskDimensions.LegalRiskRationale); - AppendDimension(builder, package, "Security", package.RiskDimensions.SecurityRisk, package.RiskDimensions.SecurityRiskRationale); - AppendDimension(builder, package, "Operational", package.RiskDimensions.OperationalRisk, package.RiskDimensions.OperationalRiskRationale); + AppendDimension(builder, package, "Legal", package.RiskDimensions.LegalRisk, + package.RiskDimensions.LegalRiskRationale); + AppendDimension(builder, package, "Security", package.RiskDimensions.SecurityRisk, + package.RiskDimensions.SecurityRiskRationale); + AppendDimension(builder, package, "Operational", package.RiskDimensions.OperationalRisk, + package.RiskDimensions.OperationalRiskRationale); builder.AppendLine("
"); builder.AppendLine("

Evidence

"); builder.AppendLine("
    "); @@ -268,11 +334,6 @@ private static string BuildHtml(string projectPath, PackageInfo[] packages) builder.AppendLine("
"); builder.AppendLine(" "); } - - builder.AppendLine("
"); - builder.AppendLine(""); - builder.AppendLine(""); - return builder.ToString(); } /// @@ -303,6 +364,29 @@ private static void AppendDimension( /// Yields label/value pairs representing the key metadata fields shown in the package detail section. /// private static IEnumerable<(string Label, string Value)> BuildDetails(PackageInfo package) + { + foreach (var item in BuildIdentityDetails(package)) + { + yield return item; + } + + foreach (var item in BuildSecurityDetails(package)) + { + yield return item; + } + + foreach (var item in BuildOperationalDetails(package)) + { + yield return item; + } + + foreach (var item in BuildReleaseDetails(package)) + { + yield return item; + } + } + + private static IEnumerable<(string Label, string Value)> BuildIdentityDetails(PackageInfo package) { string[] displayProjectPaths = GetDisplayProjectPaths(package); if (displayProjectPaths.Length > 0) @@ -312,6 +396,45 @@ private static void AppendDimension( yield return ("License", FormatLicenseDisplay(package)); + if (package.IsDeprecated != null) + { + yield return ("Deprecated package version", package.IsDeprecated.Value ? "Yes" : "No"); + } + + if (!string.IsNullOrWhiteSpace(package.LatestStableVersion)) + { + yield return ("Latest stable version", package.LatestStableVersion); + } + + if (package.LatestStablePublishedAt != null) + { + yield return ("Latest stable published", + package.LatestStablePublishedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + if (package.VersionUpdateLagDays != null) + { + yield return ("Version update lag", $"{FormatDecimal(package.VersionUpdateLagDays.Value)} days"); + } + + if (package.PublishedAt != null) + { + yield return ("Published", package.PublishedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + if (package.DownloadCount != null) + { + yield return ("Downloads", package.DownloadCount.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (package.SupportedTargetFrameworks.Length > 0) + { + yield return ("Target frameworks", string.Join(", ", package.SupportedTargetFrameworks)); + } + } + + private static IEnumerable<(string Label, string Value)> BuildSecurityDetails(PackageInfo package) + { if (package.IsPackageSigned != null) { yield return ("Package signature", package.IsPackageSigned.Value ? "Signed" : "Unsigned"); @@ -334,12 +457,14 @@ private static void AppendDimension( if (package.VerifiedCommitRatio != null) { - yield return ("Verified commit coverage", package.VerifiedCommitRatio.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return ("Verified commit coverage", + package.VerifiedCommitRatio.Value.ToString("P0", CultureInfo.InvariantCulture)); } if (package.VulnerabilityCount > 0) { - yield return ("Vulnerabilities", $"{package.VulnerabilityCount} (max severity {FormatDecimal(package.MaxVulnerabilitySeverity)})"); + yield return ("Vulnerabilities", + $"{package.VulnerabilityCount} (max severity {FormatDecimal(package.MaxVulnerabilitySeverity)})"); } if (package.MedianVulnerabilityFixDays != null) @@ -354,27 +479,32 @@ private static void AppendDimension( if (package.TransitiveVulnerabilityCount > 0) { - yield return ("Transitive vulnerabilities", package.TransitiveVulnerabilityCount.ToString(CultureInfo.InvariantCulture)); + yield return ("Transitive vulnerabilities", + package.TransitiveVulnerabilityCount.ToString(CultureInfo.InvariantCulture)); } if (package.StaleTransitiveDependencyCount != null) { - yield return ("Stale transitive dependencies", package.StaleTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Stale transitive dependencies", + package.StaleTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.AbandonedTransitiveDependencyCount != null) { - yield return ("Potentially abandoned transitive dependencies", package.AbandonedTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Potentially abandoned transitive dependencies", + package.AbandonedTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.DeprecatedTransitiveDependencyCount != null) { - yield return ("Deprecated transitive dependencies", package.DeprecatedTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Deprecated transitive dependencies", + package.DeprecatedTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.UnmaintainedCriticalTransitiveDependencyCount != null) { - yield return ("Unmaintained critical transitives", package.UnmaintainedCriticalTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Unmaintained critical transitives", + package.UnmaintainedCriticalTransitiveDependencyCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.HasNativeBinaryAssets != null) @@ -382,36 +512,30 @@ private static void AppendDimension( yield return ("Native/binary assets", package.HasNativeBinaryAssets.Value ? "Detected" : "Not detected"); } - if (package.IsDeprecated != null) - { - yield return ("Deprecated package version", package.IsDeprecated.Value ? "Yes" : "No"); - } - - if (!string.IsNullOrWhiteSpace(package.LatestStableVersion)) - { - yield return ("Latest stable version", package.LatestStableVersion); - } - - if (package.LatestStablePublishedAt != null) + if (package.HasSecurityPolicy != null) { - yield return ("Latest stable published", package.LatestStablePublishedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + yield return ("Security policy", package.HasSecurityPolicy == true ? "Present" : "Missing"); } - if (package.VersionUpdateLagDays != null) + if (package.HasDetailedSecurityPolicy != null) { - yield return ("Version update lag", $"{FormatDecimal(package.VersionUpdateLagDays.Value)} days"); + yield return ("Detailed security policy", package.HasDetailedSecurityPolicy == true ? "Detected" : "Not detected"); } - if (package.PublishedAt != null) + if (package.HasCoordinatedDisclosure != null) { - yield return ("Published", package.PublishedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + yield return ("Coordinated disclosure guidance", + package.HasCoordinatedDisclosure == true ? "Detected" : "Not detected"); } - if (package.DownloadCount != null) + if (package.HasProvenanceAttestation != null) { - yield return ("Downloads", package.DownloadCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Provenance/attestation", package.HasProvenanceAttestation == true ? "Detected" : "Not detected"); } + } + private static IEnumerable<(string Label, string Value)> BuildOperationalDetails(PackageInfo package) + { if (package.ContributorCount != null) { yield return ("Contributors", package.ContributorCount.Value.ToString(CultureInfo.InvariantCulture)); @@ -419,7 +543,8 @@ private static void AppendDimension( if (package.RecentMaintainerCount != null) { - yield return ("Active maintainers (6 months)", package.RecentMaintainerCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Active maintainers (6 months)", + package.RecentMaintainerCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.MedianMaintainerActivityDays != null) @@ -427,6 +552,12 @@ private static void AppendDimension( yield return ("Median maintainer inactivity", $"{FormatDecimal(package.MedianMaintainerActivityDays.Value)} days"); } + if (package.TopContributorShare != null) + { + yield return ("Top contributor share", + package.TopContributorShare.Value.ToString("P0", CultureInfo.InvariantCulture)); + } + if (package.OpenBugIssueCount != null) { yield return ("Open bug issues", package.OpenBugIssueCount.Value.ToString(CultureInfo.InvariantCulture)); @@ -434,12 +565,14 @@ private static void AppendDimension( if (package.ClosedBugIssueCountLast90Days != null) { - yield return ("Closed bug issues (90 days)", package.ClosedBugIssueCountLast90Days.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Closed bug issues (90 days)", + package.ClosedBugIssueCountLast90Days.Value.ToString(CultureInfo.InvariantCulture)); } if (package.ReopenedBugIssueCountLast90Days != null) { - yield return ("Reopened bug issues (90 days)", package.ReopenedBugIssueCountLast90Days.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Reopened bug issues (90 days)", + package.ReopenedBugIssueCountLast90Days.Value.ToString(CultureInfo.InvariantCulture)); } if (package.MedianIssueResponseDays != null) @@ -449,17 +582,20 @@ private static void AppendDimension( if (package.MedianCriticalIssueResponseDays != null) { - yield return ("Median critical issue response", $"{FormatDecimal(package.MedianCriticalIssueResponseDays.Value)} days"); + yield return ("Median critical issue response", + $"{FormatDecimal(package.MedianCriticalIssueResponseDays.Value)} days"); } if (package.IssueResponseCoverage != null) { - yield return ("Issue response coverage", package.IssueResponseCoverage.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return ("Issue response coverage", + package.IssueResponseCoverage.Value.ToString("P0", CultureInfo.InvariantCulture)); } if (package.IssueTriageWithinSevenDaysRate != null) { - yield return ("Issue triage within 7 days", package.IssueTriageWithinSevenDaysRate.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return ("Issue triage within 7 days", + package.IssueTriageWithinSevenDaysRate.Value.ToString("P0", CultureInfo.InvariantCulture)); } if (package.MedianOpenBugAgeDays != null) @@ -467,19 +603,24 @@ private static void AppendDimension( yield return ("Median open bug age", $"{FormatDecimal(package.MedianOpenBugAgeDays.Value)} days"); } - if (package.TopContributorShare != null) + foreach (var item in BuildWorkflowDetails(package)) { - yield return ("Top contributor share", package.TopContributorShare.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return item; } + } + private static IEnumerable<(string Label, string Value)> BuildWorkflowDetails(PackageInfo package) + { if (package.RecentFailedWorkflowCount != null) { - yield return ("Recent failed workflows", package.RecentFailedWorkflowCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Recent failed workflows", + package.RecentFailedWorkflowCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.WorkflowFailureRate != null) { - yield return ("Workflow failure rate", package.WorkflowFailureRate.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return ("Workflow failure rate", + package.WorkflowFailureRate.Value.ToString("P0", CultureInfo.InvariantCulture)); } if (package.HasFlakyWorkflowPattern != null) @@ -489,7 +630,8 @@ private static void AppendDimension( if (package.RequiredStatusCheckCount != null) { - yield return ("Required status checks", package.RequiredStatusCheckCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Required status checks", + package.RequiredStatusCheckCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.WorkflowPlatformCount != null) @@ -509,7 +651,8 @@ private static void AppendDimension( if (package.HasDependencyUpdateAutomation != null) { - yield return ("Dependency update automation", package.HasDependencyUpdateAutomation == true ? "Detected" : "Not detected"); + yield return ("Dependency update automation", + package.HasDependencyUpdateAutomation == true ? "Detected" : "Not detected"); } if (package.HasTestSignal != null) @@ -521,27 +664,10 @@ private static void AppendDimension( { yield return ("Branch protection", package.HasBranchProtection == true ? "Detected" : "Not detected"); } + } - if (package.HasProvenanceAttestation != null) - { - yield return ("Provenance/attestation", package.HasProvenanceAttestation == true ? "Detected" : "Not detected"); - } - - if (package.HasSecurityPolicy != null) - { - yield return ("Security policy", package.HasSecurityPolicy == true ? "Present" : "Missing"); - } - - if (package.HasDetailedSecurityPolicy != null) - { - yield return ("Detailed security policy", package.HasDetailedSecurityPolicy == true ? "Detected" : "Not detected"); - } - - if (package.HasCoordinatedDisclosure != null) - { - yield return ("Coordinated disclosure guidance", package.HasCoordinatedDisclosure == true ? "Detected" : "Not detected"); - } - + private static IEnumerable<(string Label, string Value)> BuildReleaseDetails(PackageInfo package) + { if (package.ReadmeUpdatedAt != null) { yield return ("README updated", package.ReadmeUpdatedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); @@ -549,7 +675,8 @@ private static void AppendDimension( if (package.ChangelogUpdatedAt != null) { - yield return ("CHANGELOG updated", package.ChangelogUpdatedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + yield return ("CHANGELOG updated", + package.ChangelogUpdatedAt.Value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } if (package.PrereleaseRatio != null) @@ -574,17 +701,20 @@ private static void AppendDimension( if (package.MajorReleaseRatio != null) { - yield return ("Major-version jump ratio", package.MajorReleaseRatio.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return ("Major-version jump ratio", + package.MajorReleaseRatio.Value.ToString("P0", CultureInfo.InvariantCulture)); } if (package.RapidReleaseCorrectionCount != null) { - yield return ("Rapid release corrections", package.RapidReleaseCorrectionCount.Value.ToString(CultureInfo.InvariantCulture)); + yield return ("Rapid release corrections", + package.RapidReleaseCorrectionCount.Value.ToString(CultureInfo.InvariantCulture)); } if (package.ExternalContributionRate != null) { - yield return ("External contribution rate", package.ExternalContributionRate.Value.ToString("P0", CultureInfo.InvariantCulture)); + yield return ("External contribution rate", + package.ExternalContributionRate.Value.ToString("P0", CultureInfo.InvariantCulture)); } if (package.UniqueReviewerCount != null) @@ -594,12 +724,8 @@ private static void AppendDimension( if (package.ReviewerDiversityRatio != null) { - yield return ("Reviewer diversity ratio", package.ReviewerDiversityRatio.Value.ToString("P0", CultureInfo.InvariantCulture)); - } - - if (package.SupportedTargetFrameworks.Length > 0) - { - yield return ("Target frameworks", string.Join(", ", package.SupportedTargetFrameworks)); + yield return ("Reviewer diversity ratio", + package.ReviewerDiversityRatio.Value.ToString("P0", CultureInfo.InvariantCulture)); } } @@ -614,7 +740,8 @@ private static string BuildRationaleContent(PackageInfo package, string rational string? licenseFileUrl = TryGetLicenseFileUrl(package); if (licenseFileUrl is not null) { - return $"{Encode(rationale)}"; + return + $"{Encode(rationale)}"; } } @@ -623,7 +750,8 @@ private static string BuildRationaleContent(PackageInfo package, string rational string? repositoryUrl = TryGetRepositoryUrl(package); if (repositoryUrl is not null) { - return $"{Encode(rationale)}"; + return + $"{Encode(rationale)}"; } } @@ -641,7 +769,8 @@ private static string BuildRationaleContent(PackageInfo package, string rational string? contributingGuideUrl = TryGetContributingGuideUrl(package); if (contributingGuideUrl is not null) { - return $"{Encode(rationale)}"; + return + $"{Encode(rationale)}"; } } @@ -650,7 +779,8 @@ private static string BuildRationaleContent(PackageInfo package, string rational string? releaseHistoryUrl = TryGetReleaseHistoryUrl(package); if (releaseHistoryUrl is not null) { - return $"{Encode(rationale)}"; + return + $"{Encode(rationale)}"; } } @@ -659,7 +789,8 @@ private static string BuildRationaleContent(PackageInfo package, string rational string? scorecardUrl = TryGetOpenSsfScorecardUrl(package); if (scorecardUrl is not null) { - return $"{Encode(rationale)}"; + return + $"{Encode(rationale)}"; } } @@ -766,7 +897,9 @@ private static string BuildRationaleContent(PackageInfo package, string rational return null; } - string repositoryIdentifier = repositoryRoot.Replace("https://github.com/", "github.com/", StringComparison.OrdinalIgnoreCase); + string repositoryIdentifier = + repositoryRoot.Replace("https://github.com/", "github.com/", StringComparison.OrdinalIgnoreCase); + return $"https://securityscorecards.dev/viewer/?uri={repositoryIdentifier}"; } @@ -785,6 +918,7 @@ private static string BuildRationaleContent(PackageInfo package, string rational repositoryUrl, @"github\.com/(?[a-zA-Z0-9._-]+)/(?[a-zA-Z0-9._-]+)", RegexOptions.IgnoreCase); + if (!match.Success) { return null; diff --git a/Src/PackageGuard/RiskSarifReportWriter.cs b/Src/PackageGuard/RiskSarifReportWriter.cs index 60db749..a123017 100644 --- a/Src/PackageGuard/RiskSarifReportWriter.cs +++ b/Src/PackageGuard/RiskSarifReportWriter.cs @@ -1,6 +1,6 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; -using System.Globalization; using PackageGuard.Core; namespace PackageGuard; @@ -76,7 +76,10 @@ private static SarifReportingDescriptor CreateRule(string id, string name, strin { Id = id, Name = name, - ShortDescription = new SarifMessage { Text = description }, + ShortDescription = new SarifMessage + { + Text = description + }, FullDescription = new SarifMessage { Text = "Review the companion HTML report for the full PackageGuard rationale and evidence." @@ -135,8 +138,14 @@ private static SarifResult CreateResult(string locationPath, PackageInfo package { PhysicalLocation = new SarifPhysicalLocation { - ArtifactLocation = new SarifArtifactLocation { Uri = locationPath }, - Region = new SarifRegion { StartLine = 1 } + ArtifactLocation = new SarifArtifactLocation + { + Uri = locationPath + }, + Region = new SarifRegion + { + StartLine = 1 + } } } ] @@ -174,9 +183,9 @@ private static string ResolveLocationPath(string projectPath) } string? projectFile = Directory.EnumerateFiles(projectPath, "*.sln").FirstOrDefault() - ?? Directory.EnumerateFiles(projectPath, "*.slnx").FirstOrDefault() - ?? Directory.EnumerateFiles(projectPath, "*.csproj").FirstOrDefault() - ?? Directory.EnumerateFiles(projectPath, "package.json").FirstOrDefault(); + ?? Directory.EnumerateFiles(projectPath, "*.slnx").FirstOrDefault() + ?? Directory.EnumerateFiles(projectPath, "*.csproj").FirstOrDefault() + ?? Directory.EnumerateFiles(projectPath, "package.json").FirstOrDefault(); return ToSarifUri(rootPath, projectFile ?? projectPath); }