tech.guitarrapc.cóm

Technical updates

プロジェクトで参照しているNuGetパッケージのライセンス一覧を取得する

C#プロジェクトで参照しているNuGetパッケージのライセンス一覧を取得する方法が気になって調べたので紹介します。

ライセンス一覧を取得する方法

ライセンス一覧を確認するには大きく2つの選択肢「dotnet cliのNuGetパッケージ一覧機能を使って自前解析」と「OSSツールを使う」があります。一通り触った感じだと、自前解析も大した手間じゃなく、OSSならsensslen/nuget-licenseとCycloneDX/cyclonedx-dotnetが一番使いやすい感じでした。

それぞれ見てみましょう。

dotnet list packageとNuGet APIを使う

dotnet cliはNuGetパッケージの一覧を取得するdotnet list packageコマンドがあります。これを使ってライセンス一覧を取得できます。ほかのツールと違って、Directory.Packages.propsも問題なく動作するのが保障されており、素直に使えるのがいいです。

まずは対象のソリューションルートで、パッケージ一覧をjsonで出力します。--include-transitiveで推移的解決される依存パッケージまでさかのぼれます。全パッケージを調査するなら一択です。

dotnet list package --include-transitive --format json > output.json
# あとはjsonを解析

JSONでファイルは以下のような感じです。

JSONの例

{
  "version": 1,
  "parameters": "--include-transitive",
  "problems": [
    {
      "level": "warning",
      "text": "(A) : Auto-referenced package."
    }
  ],
  "projects": [
    {
      "path": "D:/github/guitarrapc/csharp-lab/src/LinuxBuild/ClassLibrary/ClassLibrary.csproj",
      "frameworks": [
        {
          "framework": "net9.0"
        }
      ]
    },
    {
      "path": "D:/github/guitarrapc/csharp-lab/src/LinuxBuild/ConsoleApp/ConsoleApp.csproj",
      "frameworks": [
        {
          "framework": "net9.0"
        }
      ]
    },
    {
      "path": "D:/github/guitarrapc/csharp-lab/src/LinuxBuild/GrpcService/GrpcService.csproj",
      "frameworks": [
        {
          "framework": "net9.0",
          "topLevelPackages": [
            {
              "id": "Grpc.AspNetCore",
              "requestedVersion": "2.67.0",
              "resolvedVersion": "2.67.0"
            }
          ],
          "transitivePackages": [
            {
              "id": "Google.Protobuf",
              "resolvedVersion": "3.27.0"
            },
            {
              "id": "Grpc.AspNetCore.Server",
              "resolvedVersion": "2.67.0"
            },
            {
              "id": "Grpc.AspNetCore.Server.ClientFactory",
              "resolvedVersion": "2.67.0"
            },
            {
              "id": "Grpc.Core.Api",
              "resolvedVersion": "2.67.0"
            },
            {
              "id": "Grpc.Net.Client",
              "resolvedVersion": "2.67.0"
            },
            {
              "id": "Grpc.Net.ClientFactory",
              "resolvedVersion": "2.67.0"
            },
            {
              "id": "Grpc.Net.Common",
              "resolvedVersion": "2.67.0"
            },
            {
              "id": "Grpc.Tools",
              "resolvedVersion": "2.67.0"
            }
          ]
        }
      ]
    },
  ]
}

出力されたjsonを解析して、NuGet APIからライセンスを取得すれば一覧化できるのでサクッと用意します。継続的にCIで利用するならAPI負荷を減らすためオフラインキャッシュを使うなどの工夫が必要ですが、今回は簡単に解析するだけです。

JSONを解析してライセンス一覧取得するC#コード

using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Linq;

var path = @"output.json";
var bytes = File.ReadAllText(path);
var packages = JsonSerializer.Deserialize<DotnetCliListPackages>(bytes);
ArgumentNullException.ThrowIfNull(packages);
var licenses = new ConcurrentBag<DotnetLicense>();
var nugetLicense = new NuGetLicense();
foreach (var project in packages.Projects)
{
    var Name = project.Path;
    foreach (var framework in project.Frameworks)
    {
        if (framework.TopLevelPackages is not null)
        {
            foreach (var x in framework.TopLevelPackages)
            {
                var licenseInfo = new DotnetLicense
                {
                    Path = Name,
                    LicenseInfo = new PackageInfo
                    {
                        Id = x.Id,
                        Version = x.ResolvedVersion,
                        License = await nugetLicense.GetNuGetLicenseAsync(x.Id, x.ResolvedVersion),
                        IsTransitivePackage = false,
                    }
                };
                licenses.Add(licenseInfo);
            }
        }
        if (framework.TransitivePackages is not null)
        {
            await Parallel.ForEachAsync(framework.TransitivePackages, new ParallelOptions { MaxDegreeOfParallelism = 3 }, async (x, _) =>
            {
                var licenseInfo = new DotnetLicense
                {
                    Path = Name,
                    LicenseInfo = new PackageInfo
                    {
                        Id = x.Id,
                        Version = x.ResolvedVersion,
                        License = await nugetLicense.GetNuGetLicenseAsync(x.Id, x.ResolvedVersion),
                        IsTransitivePackage = true,
                    }
                };
                // debugger checker
                if (licenseInfo.LicenseInfo.License == "")
                {
                    var debuggerLine = "";
                }
                licenses.Add(licenseInfo);
            });
        }
    }
}

Console.WriteLine("# Show all NuGet Packages with Project Path");
// licenses.Dump();
OutputMarkdown(licenses);

Console.WriteLine("");
Console.WriteLine("# Show only NuGet Packages");
var licenseOnly = licenses
    .Select(x => x.LicenseInfo)
    .DistinctBy(x => x.Id + x.Version)
    .OrderBy(x => x.Id);
OutputMarkdown2(licenseOnly);

static void OutputMarkdown(IEnumerable<DotnetLicense> licenses)
{
    Console.WriteLine($"""
        | Path | Id | Version | License | IsTransitive |
        | --- | --- | --- | --- | --- |
        """);
    foreach (var license in licenses)
    {
        Console.WriteLine($"| {license.Path} | {license.LicenseInfo.Id} | {license.LicenseInfo.Version} | {license.LicenseInfo.License} | {license.LicenseInfo.IsTransitivePackage} |");
    }
}

static void OutputMarkdown2(IEnumerable<PackageInfo> licenses)
{
    Console.WriteLine($"""
        | Id | Version | License |
        | --- | --- | --- |
        """);
    foreach (var license in licenses)
    {
        Console.WriteLine($"| {license.Id} | {license.Version} | {license.License} |");
    }
}

// var result = await NuGetLicense.GetNuGetLicenseAsync("Grpc.AspNetCore", "2.67.0");
//result.Dump();
public class NuGetLicense
{
    private readonly HttpClient httpClient = new HttpClient();
    private readonly XNamespace ns2013 = "http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd";
    private readonly XNamespace ns2012 = "http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd";
    private readonly XNamespace ns2011 = "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd";
    private readonly Cache cache = new ();

    public async Task<string> GetNuGetLicenseAsync(string packageId, string version)
    {
        var cacheKey = packageId + "-" + version;
        if (cache.TryGetValue(cacheKey, out var license))
        {
            // Console.WriteLine($"{cacheKey}: use cache");
            return license;
        }

        // Console.WriteLine($"{cacheKey}: no cache");
        // NuGet API Format
        // https://api.nuget.org/v3-flatcontainer/{package-id}/{version}/{package-id}.nuspec
        var response = await httpClient.GetStringAsync($"https://api.nuget.org/v3-flatcontainer/{packageId}/{version}/{packageId}.nuspec");
        var doc = XDocument.Parse(response);
        //doc.Dump(); // for debug
        var licenseValue = doc.Descendants(ns2013 + "metadata")
            .Select(x => x?.Element(ns2013 + "license")?.Value)
            .FirstOrDefault()
            ?? doc.Descendants(ns2012 + "metadata")
                .Select(x => x?.Element(ns2012 + "license")?.Value)
                .FirstOrDefault()
                ?? doc.Descendants(ns2011 + "metadata")
                .Select(x => x?.Element(ns2011 + "license")?.Value)
                .FirstOrDefault();
        if (licenseValue is null)
        {
            var licenseUrl = doc.Descendants(ns2013 + "metadata")
                .Select(x => x?.Element(ns2013 + "licenseUrl")?.Value)
                .FirstOrDefault()
                ?? doc.Descendants(ns2012 + "metadata")
                    .Select(x => x?.Element(ns2012 + "licenseUrl")?.Value)
                    .FirstOrDefault()
                    ?? doc.Descendants(ns2011 + "metadata")
                    .Select(x => x?.Element(ns2011 + "licenseUrl")?.Value)
                    .FirstOrDefault();
            licenseValue = licenseUrl ?? "";
        }
        var value = licenseValue ?? "";
        cache.TryAdd(cacheKey, value);
        return value;
    }

    private class Cache()
    {
        private readonly ConcurrentDictionary<string, string> cache = new();

        public bool TryGetValue(string key, out string value)
        {
            // TODO: Add Get from offline-cache
            return cache.TryGetValue(key, out value!);
        }

        public bool TryAdd(string key, string value)
        {
            // TODO: Add Save to offline-cache
            return cache.TryAdd(key, value);
        }
    }
}

public record DotnetLicense
{
    public required string Path { get; init; }
    public required PackageInfo LicenseInfo { get; init; }
}
public record PackageInfo
{
    public required string Id { get; init; }
    public required string Version { get; init; }
    public required string License { get; init; }
    public required bool IsTransitivePackage { get; init; }
}

public record DotnetCliListPackages
{
    [JsonPropertyName("projects")]
    public required DotnetCliProjects[] Projects { get; init; }
}

public record DotnetCliProjects
{
    [JsonPropertyName("path")]
    public required string Path { get; init; }
    [JsonPropertyName("frameworks")]
    public required DotnetCliFrameworks[] Frameworks { get; init; }
}

public record DotnetCliFrameworks
{
    [JsonPropertyName("topLevelPackages")]
    public DotnetCliPackageInfo[]? TopLevelPackages { get; init; }

    [JsonPropertyName("transitivePackages")]
    public DotnetCliPackageInfo[]? TransitivePackages { get; init; }
}

public record DotnetCliPackageInfo
{
    [JsonPropertyName("id")]
    public required string Id { get; init; }
    [JsonPropertyName("resolvedVersion")]
    public required string ResolvedVersion { get; init; }
}

マークダウンで出力するので、GitHubのIssueやWikiに貼り付けるといい感じに表示できます。

image

Gistを用意したので、試してみてください。

Export NuGet Packages referenced in C# Solution, then list Licenses. - Gist

sensslen/nuget-licenseを使う

dotnet global toolとしてnuget-licenseをインストールして使うことができます。

dotnet tool install --global nuget-license

Packageとライセンス一覧をコマンド一発で出力できるので便利です。デフォルトはテーブル表示ですが、jsonに切り替えると解析しやすいのでおすすめです。出力はコンソールに表示されるので、適当にリダイレクトするといいでしょう。

nuget-license -i Csharp-lab.sln --output json

あとは適当に解析すればOKです。

JSON出力の例

[{"PackageId":"BenchmarkDotNet","PackageVersion":"0.14.0","PackageProjectUrl":"https://github.com/dotnet/BenchmarkDotNet","Copyright":".NET Foundation and contributors","Authors":".NET Foundation and contributors","License":"MIT","LicenseUrl":"https://licenses.nuget.org/MIT","LicenseInformationOrigin":0},{"PackageId":"coverlet.collector","PackageVersion":"6.0.2","PackageProjectUrl":"https://github.com/coverlet-coverage/coverlet","Authors":"tonerdo","License":"MIT","LicenseUrl":"https://licenses.nuget.org/MIT","LicenseInformationOrigin":0},{"PackageId":"FluentAssertions","PackageVersion":"7.0.0","PackageProjectUrl":"https://www.fluentassertions.com/","Copyright":"Copyright Dennis Doomen 2010-2024","Authors":"Dennis Doomen,Jonas Nyrup","License":"Apache-2.0","LicenseUrl":"https://licenses.nuget.org/Apache-2.0","LicenseInformationOrigin":0},{"PackageId":"GitHubActionsTestLogger","PackageVersion":"2.4.1","PackageProjectUrl":"https://github.com/Tyrrrz/GitHubActionsTestLogger","Copyright":"Copyright (C) Oleksii Holub","Authors":"Tyrrrz","License":"MIT","LicenseUrl":"https://licenses.nuget.org/MIT","LicenseInformationOrigin":0},{"PackageId":"Grpc.AspNetCore","PackageVersion":"2.67.0","PackageProjectUrl":"https://github.com/grpc/grpc-dotnet","Copyright":"Copyright 2019 The gRPC Authors","Authors":"The gRPC Authors","License":"Apache-2.0","LicenseUrl":"https://licenses.nuget.org/Apache-2.0","LicenseInformationOrigin":0},{"PackageId":"Grpc.AspNetCore.HealthChecks","PackageVersion":"2.67.0","PackageProjectUrl":"https://github.com/grpc/grpc-dotnet","Copyright":"Copyright 2019 The gRPC Authors","Authors":"The gRPC Authors","License":"Apache-2.0","LicenseUrl":"https://licenses.nuget.org/Apache-2.0","LicenseInformationOrigin":0},{"PackageId":"Grpc.AspNetCore.Server.Reflection","PackageVersion":"2.67.0","PackageProjectUrl":"https://github.com/grpc/grpc-dotnet","Copyright":"Copyright 2019 The gRPC Authors","Authors":"The gRPC Authors","License":"Apache-2.0","LicenseUrl":"https://licenses.nuget.org/Apache-2.0","LicenseInformationOrigin":0},{"PackageId":"IPNetwork2","PackageVersion":"3.0.667","PackageProjectUrl":"https://github.com/lduchosal/ipnetwork","Copyright":"Copyright 2022","Authors":"Luc Dvchosal","License":"https://github.com/lduchosal/ipnetwork/blob/master/LICENSE","LicenseUrl":"https://github.com/lduchosal/ipnetwork/blob/master/LICENSE","LicenseInformationOrigin":1},{"PackageId":"MemoryPack","PackageVersion":"1.21.3","PackageProjectUrl":"https://github.com/Cysharp/MemoryPack","Copyright":"\u00A9 Cysharp, Inc.","Authors":"Cysharp","License":"MIT","LicenseUrl":"https://licenses.nuget.org/MIT","LicenseInformationOrigin":0},{"PackageId":"Microsoft.AspNetCore.Components.WebAssembly","PackageVersion":"9.0.0","PackageProjectUrl":"https://asp.net/","Copyright":"\u00A9 Microsoft Corporation. All rights reserved.","Authors":"Microsoft","License":"MIT","LicenseUrl":"https://licenses.nuget.org/MIT","LicenseInformationOrigin":0}]

aaronpowell/dotnet-deliceを使う

dotnet global toolとしてdotnet-deliceをインストールして使うことができます。

dotnet tool install -g dotnet-delice

パッケージとライセンス一覧をコマンド一発で出力できるので便利です。デフォルトはツリー表示ですが、jsonに切り替えると解析しやすいのでおすすめです。出力はコンソールに表示されるので、適当にリダイレクトするといいでしょう。

$ dotnet-delice
Project Api.Shared
License Expression: BSD-3-Clause
├── There are 1 occurrences of BSD-3-Clause
├─┬ Conformance:
│ ├── Is OSI Approved: true
│ ├── Is FSF Free/Libre: true
│ └── Included deprecated IDs: false
└─┬ Packages:
  └── Google.Protobuf@3.27.0

License Expression: Apache-2.0
├── There are 8 occurrences of Apache-2.0
├─┬ Conformance:
│ ├── Is OSI Approved: true
│ ├── Is FSF Free/Libre: true

# json出力
$ dotnet-delice --json

先ほどまでのツールと違って、プロジェクトごとにライセンスタイプでパッケージがまとめられます。これはこれで便利。

JSON出力の例

{
  "projects": [
    {
      "projectName": "Api.Shared",
      "licenses": [
        {
          "expression": "BSD-3-Clause",
          "count": 1,
          "packages": [
            {
              "name": "Google.Protobuf",
              "version": "3.27.0",
              "url": "https://licenses.nuget.org/BSD-3-Clause",
              "displayName": "Google.Protobuf@3.27.0"
            }
          ],
          "isOsi": true,
          "isFsf": true,
          "isDeprecatedType": false
        },
        {
          "expression": "Apache-2.0",
          "count": 8,
          "packages": [
            {
              "name": "Grpc.AspNetCore",
              "version": "2.67.0",
              "url": "https://licenses.nuget.org/Apache-2.0",
              "displayName": "Grpc.AspNetCore@2.67.0"
            },
            {
              "name": "Grpc.AspNetCore.Server",
              "version": "2.67.0",
              "url": "https://licenses.nuget.org/Apache-2.0",
              "displayName": "Grpc.AspNetCore.Server@2.67.0"
            },
            {
              "name": "Grpc.AspNetCore.Server.ClientFactory",
              "version": "2.67.0",
              "url": "https://licenses.nuget.org/Apache-2.0",
              "displayName": "Grpc.AspNetCore.Server.ClientFactory@2.67.0"
            },
            {
              "name": "Grpc.Core.Api",
              "version": "2.67.0",
              "url": "https://licenses.nuget.org/Apache-2.0",
              "displayName": "Grpc.Core.Api@2.67.0"
            },
            {
              "name": "Grpc.Net.Client",
              "version": "2.67.0",
              "url": "https://licenses.nuget.org/Apache-2.0",
              "displayName": "Grpc.Net.Client@2.67.0"
            },
            {
              "name": "Grpc.Net.ClientFactory",
              "version": "2.67.0",
              "url": "https://licenses.nuget.org/Apache-2.0",
              "displayName": "Grpc.Net.ClientFactory@2.67.0"
            }
          ],
          "isOsi": true,
          "isFsf": true,
          "isDeprecatedType": false
        }
      ]
    }
  ]
}

CycloneDX/cyclonedx-dotnetを使う

dotnet global toolとしてnuget-licenseをインストールして使うことができます。

dotnet tool install --global CycloneDX

他のツールと違って、cyclonedx-dotnetはbom形式で出力されます。

dotnet-cyclonedx Csharp-lab.sln

デフォルトはbom.xmlに出力されます。

BOMのXML出力の例

<?xml version="1.0" encoding="utf-8"?>
<bom xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" serialNumber="urn:uuid:49d9ed4d-03a0-43fa-8140-2b6000b10d80" version="1" xmlns="http://cyclonedx.org/schema/bom/1.6">
  <metadata>
    <timestamp>2025-01-19T19:38:40.0926615Z</timestamp>
    <tools>
      <tool>
        <vendor>CycloneDX</vendor>
        <name>CycloneDX module for .NET</name>
        <version>4.2.0.0</version>
      </tool>
    </tools>
    <component type="application" bom-ref="Csharp-lab@0.0.0">
      <name>Csharp-lab</name>
      <version>0.0.0</version>
    </component>
  </metadata>
  <components>
    <component type="library" bom-ref="pkg:nuget/BenchmarkDotNet@0.14.0">
      <authors>
        <author>
          <name>.NET Foundation and contributors</name>
        </author>
      </authors>
      <name>BenchmarkDotNet</name>
      <version>0.14.0</version>
      <description>Powerful .NET library for benchmarking</description>
      <scope>required</scope>
      <hashes>
        <hash alg="SHA-512">14BAA1188A311697847A738CDC331DAD8365F40B46E950D683335E730A8EC685FF7D2A4D8E7F4EED19ADFE08D7B86A50E9B4ED4498F64886FE6AAC0D551A7E12</hash>
      </hashes>
      <licenses>
        <license>
          <id>MIT</id>
        </license>
      </licenses>
      <copyright>.NET Foundation and contributors</copyright>
      <purl>pkg:nuget/BenchmarkDotNet@0.14.0</purl>
      <externalReferences>
        <reference type="website">
          <url>https://github.com/dotnet/BenchmarkDotNet</url>
        </reference>
        <reference type="vcs">
          <url>https://github.com/dotnet/BenchmarkDotNet</url>
        </reference>
      </externalReferences>
    </component>
    <component type="library" bom-ref="pkg:nuget/BenchmarkDotNet.Annotations@0.14.0">
      <authors>
        <author>
          <name>.NET Foundation and contributors</name>
        </author>
      </authors>
      <name>BenchmarkDotNet.Annotations</name>

BOM出力はXMLなのであとは適当に解析すればできますね。

BOMを解析してライセンス一覧取得するC#コード

var path = @"bom.xml";
XNamespace ns = "http://cyclonedx.org/schema/bom/1.6";
var xml = XDocument.Load(path);

// distinct license
Console.WriteLine("# Show License types used in Solution");
var licenses = xml.Descendants(ns + "component")
  .SelectMany(component => component.Descendants(ns + "license"))
  .Select(license => license.Element(ns + "id")?.Value)
  .Where(id => !string.IsNullOrEmpty(id))
  .Distinct();
// licenses.Dump();
OutputMarkdown(licenses);
Console.WriteLine("");

// package name and license
Console.WriteLine("# Show only NuGet Packages");
var licenseList = xml.Descendants(ns + "component")
  .Where(x => x.Attribute("type")?.Value == "library")
  .Select(x =>
  {
      var name = x.Element(ns + "name")?.Value;
      var version = x.Element(ns + "version")?.Value;
      var license = x.Descendants(ns + "license")
        .Select(license => license?.Element(ns + "id")?.Value)
        .FirstOrDefault()
        ?? x.Descendants(ns + "license")
        .Select(license => license?.Element(ns + "name")?.Value)
        .FirstOrDefault();
      var url = x.Descendants(ns + "license")
        .Select(license => license?.Element(ns + "url")?.Value)
        .FirstOrDefault();
      return new NuGetLicenseSummay(name, version, license, url);
  })
  .OrderBy(x => x.Name);
// licenseList.Dump();
OutputMarkdown2(licenseList);

static void OutputMarkdown(IEnumerable<string?> licenses)
{
    Console.WriteLine($"""
        | License |
        | --- |
        """);
    foreach (var license in licenses)
    {
        Console.WriteLine($"| {license} |");
    }
}
static void OutputMarkdown2(IEnumerable<NuGetLicenseSummay> licenses)
{
    Console.WriteLine($"""
        | Name | Version | License |
        | --- | --- | --- |
        """);
    foreach (var license in licenses)
    {
        Console.WriteLine($"| {license.Name} | {license.Version} | {license.License} |");
    }
}

public record NuGetLicenseSummay(string? Name, string? Version, string? License, string? Url);

Bom結果からライセンス部分を出力した例です。

image

ライセンスは途中で変わる

ライセンスが当初のライセンスから別のライセンスに変わることは以前から度々ありました。それ自体はライブラリ作者の選択とコントリビューターの同意があれば良いだけです。利用者としては感謝ですね。ただアップグレードするだけでライセンスが変わる場合、利用者が気づけるかどうかは別問題です。ライセンスの変更は著作権にかかわる重要な情報ですが、現在のNuGetではVisual Studioで更新したり、.csprojやDependabotでバージョン更新したときに特別に注意や表示は出ないため、バージョン更新でライセンスが変わってもおそらく気づけません。私は自信ないです。

今回のライセンス一覧化は、前々から気になっていたライブラリが途中でライセンス変わった場合、気づけなくない? やばくない? に対する、いったんの回答です。

パッケージごとのライセンスが解析できれば、パッケージ更新時にライセンスが変わったことも検知できるようになります。バージョン更新があるたびに定期的にライセンスを出力すれば、許可されていないライセンスが含まれていないかをチェックできます。バージョン更新でライセンスが変わる問題に対する現実解は、これをCIに組み込むことでしょう。

運用で利用するにはもう少し考慮点はありますが、NuGetパッケージ一覧やsbom出力が一般化してライセンス一覧を取得するのは以前より簡単になっています。

まとめ

dotnet cliにパッケージ出力機能がつく前は.csprojの解析が必要で面倒でしたが、今ならパッケージとバージョン一覧を出力できます。推移的解決されたパッケージも出力されるので、ライセンス一覧を取得するのも簡単です。

ツールを使っても、自分で解析しても便利なので使っていくのもいいでしょう。