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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ At a glance, PackageGuard can:

- Scan **NuGet, npm, pnpm and Yarn** dependencies across an entire solution or codebase in one run, direct and transitive alike
- **Scaffold a starting configuration** (`packageguard init`) from the licenses actually found in your repository, flagging copyleft licenses and suggesting a policy that fits whether your software is proprietary, SaaS, or open source
- Enforce **allow- and deny-lists** for open-source licenses, specific packages, and package versions, discovered **hierarchically** across solution-, project- and repository-level configuration files
- Enforce **allow- and deny-lists** for open-source licenses (including SPDX expressions such as `MIT OR Apache-2.0`), specific packages, and package versions, discovered **hierarchically** across solution-, project- and repository-level configuration files
- Resolve **licenses** from NuGet/npm metadata, GitHub repositories, and downloaded license text through a chain of fetchers, falling back gracefully when a source doesn't have an answer
- **Explain** a single package on demand (`packageguard explain <package>`, with fuzzy name matching) - its dependency path, how its version was resolved, the exact policy rule and configuration file that allowed or denied it, and its risk breakdown
- Find **redundant package references** and **version conflicts** across projects (`packageguard dependencies`), offline and without touching your policy
Expand Down
6 changes: 3 additions & 3 deletions Src/PackageGuard.Core/CSharp/CSharpProjectAnalysisStrategy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ private PolicyViolation[] VerifyAgainstPolicy(PackageInfoCollection packages, Pr
/// </summary>
private static void UpdateLicensePolicyCompatibility(PackageInfo package, ProjectPolicy policy)
{
bool allowlistCompatible = !policy.AllowList.Licenses.Any() ||
policy.AllowList.Licenses.Contains(package.License!, StringComparer.OrdinalIgnoreCase);
bool allowlistCompatible = policy.AllowList.Licenses.Count == 0 ||
LicenseExpression.FindAllowingEntries(package.License, policy.AllowList.Licenses).Count > 0;

bool denylistCompatible = !policy.DenyList.Licenses.Contains(package.License!, StringComparer.OrdinalIgnoreCase);
bool denylistCompatible = LicenseExpression.FindRestrictingEntries(package.License, policy.DenyList.Licenses).Count == 0;

bool compatible = allowlistCompatible && denylistCompatible;
package.IsLicensePolicyCompatible ??= true;
Expand Down
22 changes: 8 additions & 14 deletions Src/PackageGuard.Core/Policy/AllowList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ internal PolicyDecision EvaluateAllow(PackageInfo package)
};
}

bool licenseComplies = !(Licenses.Any() && !Licenses.Contains(package.License!, StringComparer.OrdinalIgnoreCase));
IReadOnlyList<string> matchingLicenses = LicenseExpression.FindAllowingEntries(package.License, Licenses);
bool licenseComplies = Licenses.Count == 0 || matchingLicenses.Count > 0;

foreach (PackageSelector selector in Packages)
{
Expand Down Expand Up @@ -107,21 +108,14 @@ internal PolicyDecision EvaluateAllow(PackageInfo package)
};
}

if (Licenses.Any())
if (matchingLicenses.Count > 0)
{
string? matchingLicense = Licenses.FirstOrDefault(license =>
license.Equals(package.License, StringComparison.OrdinalIgnoreCase));

if (matchingLicense is not null)
return new PolicyDecision
{
LicenseSourceFiles.TryGetValue(matchingLicense, out string? licenseSourceFile);
return new PolicyDecision
{
IsMatch = true,
Reason = $"matches allow list license entry \"{matchingLicense}\"",
SourceFile = licenseSourceFile
};
}
IsMatch = true,
Reason = LicenseExpression.DescribeMatch("allow list", matchingLicenses, package.License),
SourceFile = FindLicenseSourceFiles(matchingLicenses)
};
}

return new PolicyDecision
Expand Down
25 changes: 10 additions & 15 deletions Src/PackageGuard.Core/Policy/DenyList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,23 +100,18 @@ internal PolicyDecision EvaluateDeny(PackageInfo package)
};
}

if (Licenses.Any())
IReadOnlyList<string> matchingLicenses = LicenseExpression.FindRestrictingEntries(package.License, Licenses);
if (matchingLicenses.Count > 0)
{
string? matchingLicense = Licenses.FirstOrDefault(license =>
license.Equals(package.License, StringComparison.OrdinalIgnoreCase));

if (matchingLicense is not null)
string? licenseSourceFiles = FindLicenseSourceFiles(matchingLicenses);
return new PolicyDecision
{
LicenseSourceFiles.TryGetValue(matchingLicense, out string? licenseSourceFile);
return new PolicyDecision
{
IsMatch = true,
Reason = $"matches deny list license entry \"{matchingLicense}\"",
SourceFile = licenseSourceFile,
Resolution = $"Use a package with a different license, or remove \"{matchingLicense}\" from the deny " +
$"section of {DescribeConfigurationFile(licenseSourceFile)}"
};
}
IsMatch = true,
Reason = LicenseExpression.DescribeMatch("deny list", matchingLicenses, package.License),
SourceFile = licenseSourceFiles,
Resolution = $"Use a package with a different license, or remove {LicenseExpression.JoinQuoted(matchingLicenses, "or")} " +
$"from the deny section of {DescribeConfigurationFile(licenseSourceFiles)}"
};
}

foreach (PackageSelector selector in Packages)
Expand Down
275 changes: 275 additions & 0 deletions Src/PackageGuard.Core/Policy/LicenseExpression.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
using System.Text.RegularExpressions;

namespace PackageGuard.Core.Policy;

/// <summary>
/// A parsed SPDX license expression, such as <c>MIT OR Apache-2.0</c> or
/// <c>GPL-2.0-only WITH Classpath-exception-2.0</c>. A license that is not a valid expression, such as free text,
/// becomes a single <see cref="Term"/> that holds the text as is.
/// </summary>
internal abstract class LicenseExpression
{
private const string Or = "OR";
private const string And = "AND";
private const string With = "WITH";

private static readonly Regex Tokenizer = new(@"\(|\)|[^\s()]+", RegexOptions.Compiled);

/// <summary>
/// Deprecated GNU identifiers that SPDX split into an <c>-only</c> and an <c>-or-later</c> variant.
/// </summary>
private static readonly string[] DeprecatedGnuIds =
[
"GPL-1.0", "GPL-2.0", "GPL-3.0", "LGPL-2.0", "LGPL-2.1", "LGPL-3.0", "AGPL-1.0", "AGPL-3.0",
"GFDL-1.1", "GFDL-1.2", "GFDL-1.3"
];

/// <summary>
/// Parses <paramref name="license"/>. Operators are not case-sensitive.
/// </summary>
public static LicenseExpression Parse(string license)
{
var parser = new Parser(Tokenizer.Matches(license).Select(match => match.Value).ToArray());
LicenseExpression? expression = parser.ParseOr();

return expression is not null && parser.IsAtEnd ? expression : new Term(license.Trim(), null);
}

/// <summary>
/// Finds the entries in <paramref name="entries"/> that allow <paramref name="license"/>. An <c>OR</c> needs one
/// allowed operand, since the consumer can choose. An <c>AND</c> needs every operand to be allowed. A <c>WITH</c>
/// term is allowed by an entry for the full term or for the license without the exception.
/// </summary>
/// <returns>The entries that together allow the license, or an empty list if the license is not allowed.</returns>
public static IReadOnlyList<string> FindAllowingEntries(string? license, ICollection<string> entries)
{
if (string.IsNullOrWhiteSpace(license) || entries.Count == 0)
{
return [];
}

LicenseExpression expression = Parse(license);

return AsMatch(FindEntry(entries, expression.ToString())) ?? expression.Evaluate(
term => AsMatch(FindEntry(entries, term.ToString()) ?? FindEntry(entries, term.Id) ?? FindEntry(entries, term.BaseId)),
(left, right) => left ?? right,
Combine) ?? [];
}

/// <summary>
/// Finds the entries in <paramref name="entries"/> that restrict <paramref name="license"/>, as a deny list or a
/// warn list does. An <c>OR</c> is only restricted if every operand is, since one acceptable option is enough. An
/// <c>AND</c> is restricted if any operand is. A <c>WITH</c> term is only restricted by an entry for the full
/// term, since the exception is what makes it acceptable.
/// </summary>
/// <returns>The entries that together restrict the license, or an empty list if the license is not restricted.</returns>
public static IReadOnlyList<string> FindRestrictingEntries(string? license, ICollection<string> entries)
{
if (string.IsNullOrWhiteSpace(license) || entries.Count == 0)
{
return [];
}

LicenseExpression expression = Parse(license);

return AsMatch(FindEntry(entries, expression.ToString())) ?? expression.Evaluate(
term => AsMatch(FindEntry(entries, term.ToString())),
Combine,
(left, right) => left ?? right) ?? [];
}

/// <summary>
/// Describes that <paramref name="entries"/> in the named list matched <paramref name="license"/>, and names the
/// full license when the entries only matched part of it.
/// </summary>
public static string DescribeMatch(string listName, IReadOnlyList<string> entries, string? license)
{
string match = entries.Count == 1
? $"matches {listName} license entry {JoinQuoted(entries, "and")}"
: $"matches {listName} license entries {JoinQuoted(entries, "and")}";

return entries.Count == 1 && entries[0].Equals(license, StringComparison.OrdinalIgnoreCase)
? match
: $"{match} (from \"{license}\")";
}

/// <summary>
/// Quotes each of <paramref name="entries"/> and joins them with <paramref name="conjunction"/>, such as
/// <c>"MIT" and "Apache-2.0"</c>.
/// </summary>
public static string JoinQuoted(IEnumerable<string> entries, string conjunction) =>
string.Join($" {conjunction} ", entries.Select(entry => $"\"{entry}\""));

/// <summary>
/// Folds this expression into a single value, using <paramref name="term"/> for every single license and
/// <paramref name="or"/> and <paramref name="and"/> to combine the operands of compound expressions.
/// </summary>
public abstract T Evaluate<T>(Func<Term, T> term, Func<T, T, T> or, Func<T, T, T> and);

/// <summary>
/// Returns the normalized form of this expression, which is used to compare it to configured entries. The operands
/// of <c>OR</c> and <c>AND</c> are sorted, since their order has no meaning.
/// </summary>
public abstract override string ToString();

private static string[]? AsMatch(string? entry) => entry is null ? null : [entry];

private static string[]? Combine(string[]? left, string[]? right) =>
left is not null && right is not null ? [..left.Union(right, StringComparer.OrdinalIgnoreCase)] : null;

// ponytail: parses every entry on each lookup; cache the parsed entries if big policies make this slow
private static string? FindEntry(IEnumerable<string> entries, string? normalizedLicense) =>
normalizedLicense is null
? null
: entries.FirstOrDefault(entry =>
Parse(entry).ToString().Equals(normalizedLicense, StringComparison.OrdinalIgnoreCase));

private static string Normalize(string id)
{
bool isOrLater = id.EndsWith('+');
string baseId = isOrLater ? id[..^1] : id;

string? deprecatedId = DeprecatedGnuIds.FirstOrDefault(gnuId => gnuId.Equals(baseId, StringComparison.OrdinalIgnoreCase));
if (deprecatedId is null)
{
return id;
}

return deprecatedId + (isOrLater ? "-or-later" : "-only");
}

/// <summary>
/// A single license, such as <c>MIT</c>, optionally with an exception, such as
/// <c>GPL-2.0-only WITH Classpath-exception-2.0</c>.
/// </summary>
public sealed class Term(string id, string? exceptionId) : LicenseExpression
{
/// <summary>
/// Gets the license identifier, with deprecated identifiers replaced by their current form.
/// </summary>
public string Id { get; } = Normalize(id);

/// <summary>
/// Gets the version this "or later" term starts from, such as <c>GPL-2.0-only</c> for <c>GPL-2.0-or-later</c>,
/// or <see langword="null"/> if the term is not an "or later" license.
/// </summary>
public string? BaseId =>
Id.EndsWith('+') ? Id[..^1] :
Id.EndsWith("-or-later", StringComparison.OrdinalIgnoreCase) ? Id[..^"-or-later".Length] + "-only" :
null;

/// <summary>
/// Gets the exception of a <c>WITH</c> term, such as <c>Classpath-exception-2.0</c>.
/// </summary>
private string? ExceptionId { get; } = exceptionId;

/// <inheritdoc/>
public override T Evaluate<T>(Func<Term, T> term, Func<T, T, T> or, Func<T, T, T> and) => term(this);

/// <inheritdoc/>
public override string ToString() => ExceptionId is null ? Id : $"{Id} {With} {ExceptionId}";
}

/// <summary>
/// Two expressions combined with <c>OR</c> or <c>AND</c>.
/// </summary>
private sealed class Compound(string @operator, LicenseExpression left, LicenseExpression right) : LicenseExpression
{
private string Operator { get; } = @operator;

public override T Evaluate<T>(Func<Term, T> term, Func<T, T, T> or, Func<T, T, T> and)
{
T leftValue = left.Evaluate(term, or, and);
T rightValue = right.Evaluate(term, or, and);

return Operator == Or ? or(leftValue, rightValue) : and(leftValue, rightValue);
}

public override string ToString() =>
string.Join($" {Operator} ", GetOperands().Select(Wrap).Order(StringComparer.OrdinalIgnoreCase));

/// <summary>
/// Returns the operands of this expression, including those of nested expressions with the same operator, so
/// <c>A OR (B OR C)</c> and <c>(C OR B) OR A</c> have the same normalized form.
/// </summary>
private IEnumerable<LicenseExpression> GetOperands() => Flatten(left).Concat(Flatten(right));

private IEnumerable<LicenseExpression> Flatten(LicenseExpression operand) =>
operand is Compound compound && compound.Operator == Operator ? compound.GetOperands() : [operand];

private static string Wrap(LicenseExpression operand) =>
operand is Compound ? $"({operand})" : operand.ToString();
}

/// <summary>
/// A recursive descent parser for <c>or := and (OR and)*</c>, <c>and := term (AND term)*</c> and
/// <c>term := "(" or ")" | id [WITH id]</c>. Each method returns <see langword="null"/> when the tokens do not
/// form a valid expression.
/// </summary>
private sealed class Parser(string[] tokens)
{
private int position;

public bool IsAtEnd => position == tokens.Length;

public LicenseExpression? ParseOr() => ParseBinary(Or, ParseAnd);

private LicenseExpression? ParseAnd() => ParseBinary(And, ParseTerm);

private LicenseExpression? ParseBinary(string @operator, Func<LicenseExpression?> parseOperand)
{
LicenseExpression? left = parseOperand();
while (left is not null && Accept(@operator))
{
LicenseExpression? right = parseOperand();
left = right is null ? null : new Compound(@operator, left, right);
}

return left;
}

private LicenseExpression? ParseTerm()
{
if (Accept("("))
{
LicenseExpression? inner = ParseOr();
return Accept(")") ? inner : null;
}

string? id = NextIdentifier();
if (id is null || !Accept(With))
{
return id is null ? null : new Term(id, null);
}

string? exceptionId = NextIdentifier();
return exceptionId is null ? null : new Term(id, exceptionId);
}

private string? NextIdentifier()
{
if (IsAtEnd || tokens[position] is "(" or ")" || IsOperator(tokens[position]))
{
return null;
}

return tokens[position++];
}

private bool Accept(string token)
{
if (IsAtEnd || !tokens[position].Equals(token, StringComparison.OrdinalIgnoreCase))
{
return false;
}

position++;
return true;
}

private static bool IsOperator(string token) =>
token.Equals(Or, StringComparison.OrdinalIgnoreCase) ||
token.Equals(And, StringComparison.OrdinalIgnoreCase) ||
token.Equals(With, StringComparison.OrdinalIgnoreCase);
}
}
15 changes: 15 additions & 0 deletions Src/PackageGuard.Core/Policy/PackagePolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,19 @@ public abstract class PackagePolicy
/// the file is named when it is known, and the hint falls back to "your config" when it is not.
/// </summary>
private protected static string DescribeConfigurationFile(string? sourceFile) => sourceFile ?? "your config";

/// <summary>
/// Finds the configuration files that introduced <paramref name="licenses"/>, separated by commas, or
/// <see langword="null"/> if none of them is known.
/// </summary>
private protected string? FindLicenseSourceFiles(IEnumerable<string> licenses)
{
string[] sourceFiles = licenses
.Select(license => LicenseSourceFiles.GetValueOrDefault(license))
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();

return sourceFiles.Length == 0 ? null : string.Join(", ", sourceFiles);
}
}
Loading
Loading