tech.guitarrapc.cóm

Technical updates

GitHub Actionsで他のワークフローやリポジトリのアーティファクトをダウンロードする

GitHub Actionsでアーティファクトのアップロードはactions/upload-artifact、ダウンロードはactions/download-artifactで行います。普段は同一ワークフローのジョブ間でアーティファクトを受け渡すのに使っていますが、他のワークフローやリポジトリのアーティファクトをダウンロードしたい場合もあります。

例えば、あるワークフローでネイティブビルドを行っていて10分かかります。そのビルド成果物を別のワークフローで使い回したいときに、改めてビルドすることなくダウンロードできます。ビルドで一番つらいのは時間がかかることなので、ビルド成果物を使い回せると助かりますよね。

今回は、actions/download-artifactでワークフローやリポジトリを跨いで、アーティファクトをダウンロードできるという話です。シラナカッタ。

何ができるのか

actions/download-artifact(v4以降/v3とv4の差分)を使って、他のワークフローやリポジトリのアーティファクトをダウンロードできます。例えば次のように書くと、run-idで指定した別ワークフローの実行履歴11100002222でアップロードされたすべてのアーティファクトを、今のワークフローにダウンロードします。

- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
  with:
    run-id: "11100002222"
    github-token: ${{ github.token }}
  • run-id: 対象ワークフローの履歴ページURLの末尾にある数字列。例えば、https://github.com/<ORG>/<REPO>/actions/runs/123456789なら123456789
  • github-token: アクセス権限のあるGitHubトークン。同一リポジトリなら${{ github.token }}を指定すればOK。通常は無指定なので、指定必須
  • name: 省略するとすべてのアーティファクトをダウンロード、特定の名前のアーティファクトだけ欲しい場合は指定

なお、ドキュメントにはactions: read権限をジョブにつけるように書かれていますが、同一リポジトリの場合は権限を指定しなくてもダウンロードできます。

使い方

私のリポジトリguitarrapc/githubactions-labを使って実際の挙動を見つつ使い方を確認しましょう。 今回は、同一リポジトリ内の他ワークフローからアーティファクトをダウンロードします。

アーティファクトをアップロードするワークフローを用意する

まずは、actions/upload-artifactでアップロードするワークフローartifacts-targz.yamlを用意して実行します。(artifacts (tar.gz) #378)

URLから、この時のrun-id21203098493とわかります。

ワークフロー実行結果にoutput.tar.gzが添付されている

name: artifacts (tar.gz)
on:
  workflow_dispatch:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  # tar.gz
  upload-targz:
    permissions:
      contents: read
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: output
        run: |
          mkdir -p ./output/bin
          echo "hoge" > ./output/hoge.txt
          echo "fuga" > ./output/fuga.txt
          echo "foo" > ./output/bin/foo.txt
          echo "bar" > ./output/bin/bar.txt
          tar -zcvf output.tar.gz ./output/
      - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
        with:
          name: output.tar.gz
          path: ./output.tar.gz
          retention-days: 1

  download-targz:
    needs: [upload-targz]
    # ... ただのアップロード確認なので省略

アップロードができたら、次に他のワークフローからダウンロードしてみましょう。

他のワークフローのアーティファクトをダウンロードする

次に、上記ワークフローでアップロードしたアーティファクトをダウンロードするワークフローartifacts-other-workflow.yamlを用意します。 run-idを指定した場合は「run-idの履歴」から、指定しなかった場合は「最新の成功ビルド履歴」からアーティファクトをダウンロードします。

name: artifacts (other workflow)
on:
  workflow_dispatch:
    inputs:
      workflow-name:
        description: "Workflow name to download artifacts from"
        required: true
        default: "artifacts-targz.yaml"
      run-id:
        description: "Run ID to download artifacts from (optional)"
        required: false
        default: ""

jobs:
  download-directory:
    permissions:
      contents: read
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    steps:
      - name: List Run Ids of specified workflow
        run: gh run list -w ${{ inputs.workflow-name }} --status completed --limit 5
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ github.token }}
      - name: Get latest Run Id of specified workflow
        if: ${{ inputs.run-id == '' }}
        id: get-run-id
        run: |
          run_id=$(gh run list -w ${{ inputs.workflow-name }} --status completed --limit 1 --json databaseId --jq ".[].databaseId")
          echo "run_id=$run_id" | tee -a "$GITHUB_OUTPUT"
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ github.token }}
      - name: Get run details
        run: gh run view ${{ inputs.run-id || steps.get-run-id.outputs.run_id }}
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ github.token }}
      - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
        with:
          run-id: ${{ inputs.run-id || steps.get-run-id.outputs.run_id }}
          github-token: ${{ github.token }}
      - name: ls
        run: ls -lR

実行してみましょう。run-idを指定してもいいですが、今回は空にして最新のビルドからダウンロードさせます。

workflow_dispatchで実行する

実行履歴をみると、指定したワークフローの最新成功ビルド履歴のrun-id21203098493からダウンロードできています。

actions/download-artifactで他ワークフローのアーティファクトをダウンロードできている

Run actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
  with:
    run-id: 21203098493
    github-token: ***
    merge-multiple: false
    repository: guitarrapc/githubactions-lab
Fetching artifact list for workflow run 21203098493 in repository guitarrapc/githubactions-lab
Found 1 artifact(s)
No input name, artifact-ids or pattern filtered specified, downloading all artifacts
An extra directory with the artifact name will be created for each download
Preparing to download the following artifacts:
- output.tar.gz (ID: 5201415391, Size: 379, Expected Digest: sha256:81f29b05074a1ed328654302ee20a68e087f13788371ad1ef3cdeb2ddf4c0972)
Downloading artifact '5201415391' from 'guitarrapc/githubactions-lab'
Redirecting to blob download url: https://productionresultssa10.blob.core.windows.net/actions-results/751cc8ca-c811-458d-9fb6-fb2a0a957314/workflow-job-run-a66f81bc-92fb-527d-84b2-0d7efafd23ba/artifacts/3dcf0ac43e2ba1e4dda83cc9cad71ef1586214d36718776b88b6e1d93987cce0.zip
Starting download of artifact to: /home/runner/work/githubactions-lab/githubactions-lab
(node:2030) [DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
SHA256 digest of downloaded artifact is 81f29b05074a1ed328654302ee20a68e087f13788371ad1ef3cdeb2ddf4c0972
Artifact download completed successfully.
Total of 1 artifact(s) downloaded
Download artifact has finished successfully

ワークフローを跨いでアーティファクトをダウンロードできるんですねー!

トラブルシュート

いくつか遭遇したトラブルとその対処法を紹介します。

run-idを指定したのにアーティファクトが見つからない

デフォルトのactions/download-artifactは、github-tokenが空のためrun-idだけ指定してもアーティファクトが見つからないと出ます。

例えば、次の設定だとgithub-tokenが無指定なのでアーティファクトが見つかりません。エラーログからはトークンがないことためと分からないのでハマりやすいです。ハマった。

# ❌
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
  with:
    run-id: 1234
Found 0 artifact(s)
No input name, artifact-ids or pattern filtered specified, downloading all artifacts
An extra directory with the artifact name will be created for each download
Total of 0 artifact(s) downloaded
Download artifact has finished successfully

この場合、github-token${{ github.token }}やPATを指定しましょう。

# OK
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
  with:
    run-id: 1234
    github-token: ${{ github.token }}

github-tokenとrun-idを指定したのにアーティファクトが見つからない

別のリポジトリの場合、ワークフローで自動発行されるトークン${{ github.token }}ではアクセスできません。

# ❌
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
  with:
    run-id: 9876
    repository: some/other-repo
    github-token: ${{ github.token }}

この場合、PATかGitHub Appのインストールアクセストークンを使う必要があります。

# OK
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
  with:
    run-id: 9876
    repository: some/other-repo
    github-token: ${{ secrets.YOUR_PAT }}

また、PATにはactions: read権限が必要です。

To elevate permissions for this scenario, you can specify a github-token along with other repository and run identifiers:

公式の案内でactions:read権限が必要と明記されている

権限に問題がないがアーティファクトが見つからない

actions/upload-artifactでアーティファクトをアップロードしたアーティファクトは、一定の期間だけ保持されます。デフォルトで特に指定なければ90日間ですが、ワークフローでretention-daysを指定している場合はその日数になります。

私はよくretention-days: 1にしているため、翌日にはアーティファクトが消えていて見つからなくなります。アーティファクトが見つからない場合は、対象のrun-idのアーティファクトがまだ存在しているか確認してください。

期限が切れている場合、次のようにExpiredと表示されます。

アーティファクトの期限が切れてExpiredと表示されている

まとめ

actions/download-artifact@v3までは同一ワークフローからしか取得できなかったので、てっきり今もかと考えていましたが、v4(2023年12月15日リリース)以降は他のワークフローやリポジトリのアーティファクトもダウンロードできます。

中にはGitHub APIを駆使して取得する例もありますが、アーティファクトをAPIで触るのは割と面倒です。actions/download-artifactを使えるシーンでは積極的に使っていきましょう。

参考

C#/NuGetにおけるSBOMとSLSAの状況

前回記事で、OSS開発者としてSBOMとSLSAの状況を見ました。 今回は、C#/NuGetでSBOMやSLSAはいい感じに使えるのかという点を見ていきます。

結論から言うと、NuGetでSBOMは対応可能ですが、SLSAは現状では機能しません。特にNuGetの署名の仕組みが障壁になっています。具体的に見ていきましょう。

NuGetのSBOM対応

SBOMで紹介したsbom-toolと同じリポジトリから提供されているMicrosoft.Sbom.TargetsNuGetパッケージを組み込んでSBOMを有効にすれば、dotnet packで自動的にsbomを.nupkgに埋め込みます。

dotnet add package Microsoft.Sbom.Targets

基本的な設定方法

参考までに、SkiaSharp.QrCodeでSBOM対応しています。

Microsoft.Sbom.Targetsは、内部的にはMicrosoft.SbomToolを用いてSPDXフォーマットのSBOMを生成します。NuGetパッケージ追加後、SBOM生成を有効にするにはcsproj<GenerateSBOM>true</GenerateSBOM>を設定します。おすすめ設定は次の通りですが、事実上<GenerateSBOM>true</GenerateSBOM>だけで十分です。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>

    <!-- 👇 これを追加 -->
    <GenerateSBOM>true</GenerateSBOM>
    <!-- 👇 以下はバグで機能しない -->
    <SbomGenerationFetchLicenseInformation>true</SbomGenerationFetchLicenseInformation>
    <SbomGenerationEnablePackageMetadataParsing>true</SbomGenerationEnablePackageMetadataParsing>
    <!-- 👇 指定しなければ2.2なのでそれでもいい。3.0は時期尚早 -->
    <SbomGenerationManifestInfo>SPDX:2.2</SbomGenerationManifestInfo>
  </PropertyGroup>

  <ItemGroup>
    <PackageVersion Include="Microsoft.Sbom.Targets" Version="4.1.5" />
  </ItemGroup>
</Project>

他にも.csprojには様々なSBOM生成オプションを設定できます。

dotnet packでnuget用のパッケージを生成すると、.nupkgにSBOMが含まれます。デフォルトなら、_manifest/spdx_2.2/manifest.spdx.jsonにSBOMマニフェストが含まれているはずです1

$ dotnet pack -c Release
$ ls -l src/SkiaSharp.QrCode/bin/Release
total 592
-rw-rw-r--    1 guitarrapc guitarrapc    174157 Jan 20 00:23 SkiaSharp.QrCode.1.0.0.nupkg
-rw-rw-r--    1 guitarrapc guitarrapc    123261 Jan 18 19:07 SkiaSharp.QrCode.1.0.0.snupkg
drwxrwxr-x    2 guitarrapc guitarrapc         0 Jan 18 19:07 net10.0
drwxrwxr-x    2 guitarrapc guitarrapc         0 Jan 18 19:07 net8.0
drwxrwxr-x    2 guitarrapc guitarrapc         0 Jan 18 19:07 netstandard2.0
drwxrwxr-x    2 guitarrapc guitarrapc         0 Jan 18 19:07 netstandard2.1

$ unzip -l src/SkiaSharp.QrCode/bin/Release/SkiaSharp.QrCode.1.0.0.nupkg |   Length      Date    Time    Name
---------  ---------- -----   ----
    33143  15/01/2026 08:33   README.md
     2212  18/01/2026 19:07   SkiaSharp.QrCode.nuspec
      527  18/01/2026 19:07   [Content_Types].xml
      510  18/01/2026 19:07   _rels/.rels
    85504  18/01/2026 10:07   lib/net10.0/SkiaSharp.QrCode.dll
    86016  18/01/2026 10:07   lib/net8.0/SkiaSharp.QrCode.dll
    91648  18/01/2026 10:07   lib/netstandard2.0/SkiaSharp.QrCode.dll
    87552  18/01/2026 10:07   lib/netstandard2.1/SkiaSharp.QrCode.dll
    19578  20/01/2026 00:23   _manifest/spdx_2.2/manifest.spdx.json
       64  20/01/2026 00:23   _manifest/spdx_2.2/manifest.spdx.json.sha256
      694  18/01/2026 19:07   package/services/metadata/core-properties/4056208419134658ab90d43eda3a51e4.psmdcp
---------                     -------
   407448                     11 files

中身は次のようになっています。一部要素を省略していますが、全文は折りたたんでおくので、必要があれば展開してください。

SBOMの中身全文(クリックで展開)

{
  "files": [
    {
      "fileName": "./lib/net8.0/SkiaSharp.QrCode.dll",
      "SPDXID": "SPDXRef-File--lib-net8.0-SkiaSharp.QrCode.dll-A1F20BD93B1A73FE0D4F34E57FE5E83CF16E748C",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "64884baa46df69ae1eed437e8a47e271399ffe215dad7577eb7106f0a429010f"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "a1f20bd93b1a73fe0d4f34e57fe5e83cf16e748c"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./lib/netstandard2.1/SkiaSharp.QrCode.dll",
      "SPDXID": "SPDXRef-File--lib-netstandard2.1-SkiaSharp.QrCode.dll-0442B620957CC8FE998F26BD186AF7DBB6478C4A",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "26c0489039142d6afd6fc6d65361f1d4b2160762571ed4f9283c311d02776069"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "0442b620957cc8fe998f26bd186af7dbb6478c4a"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./SkiaSharp.QrCode.nuspec",
      "SPDXID": "SPDXRef-File--SkiaSharp.QrCode.nuspec-E7ABF4725060CDB3E4F6D0F981A6D650D559A7A7",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "7b6eb335fcda9fed0857249bad273623f420cdaa14fb888a215275663544b8f6"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "e7abf4725060cdb3e4f6d0f981a6d650d559a7a7"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./lib/netstandard2.0/SkiaSharp.QrCode.dll",
      "SPDXID": "SPDXRef-File--lib-netstandard2.0-SkiaSharp.QrCode.dll-C1454A9087D55424B935EF97EA7128FBE6144C36",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "5906d89879bdc064f05f6d8bcfbc1fe71ad09c7e063f96cef46d442aa8c3eeea"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "c1454a9087d55424b935ef97ea7128fbe6144c36"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./_rels/.rels",
      "SPDXID": "SPDXRef-File---rels-.rels-9D0981B47DA2D0B8488D17207B3F4CC3F2C99E91",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "a6a9610719dbe9bd2ef2283df0e4c54e71b65003c5a0a2940d54936dc99c78d9"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "9d0981b47da2d0b8488d17207b3f4cc3f2c99e91"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./README.md",
      "SPDXID": "SPDXRef-File--README.md-41D1013908E6838C84BB5B113F0BAAEE042E1346",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "de4b28cb1a9f33a300652715220e5eee98796cae1aef6447f01d286664307bf9"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "41d1013908e6838c84bb5b113f0baaee042e1346"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./lib/net10.0/SkiaSharp.QrCode.dll",
      "SPDXID": "SPDXRef-File--lib-net10.0-SkiaSharp.QrCode.dll-99204E5B36AA5D3E01AB797497A43D3DE479B2A6",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "d05d2cc4445c0d521993ccede87b1bacf3998defc3a2f24133895cdac132a890"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "99204e5b36aa5d3e01ab797497a43d3de479b2a6"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./package/services/metadata/core-properties/c74095c9b98b496db5bfeb7f0ec286d6.psmdcp",
      "SPDXID": "SPDXRef-File--package-services-metadata-core-properties-c74095c9b98b496db5bfeb7f0ec286d6.psmdcp-F931762800D5440480186EB37E0469B74BF2C8AF",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "0f530c34dffec3ae38de28b1549202ac49e08ff22e9ad4b6a6c83c350c8e8ca9"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "f931762800d5440480186eb37e0469b74bf2c8af"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./[Content_Types].xml",
      "SPDXID": "SPDXRef-File---Content-Types-.xml-231C3DFEA27B519CD97D24A63F7B8C8B99AD464E",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "778a7d54d9a1c7caf79ab7002592349d933aae8edf8385f1fc56178d7a069143"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "231c3dfea27b519cd97d24a63f7b8c8b99ad464e"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    }
  ],
  "packages": [
    {
      "name": "SkiaSharp.QrCode",
      "SPDXID": "SPDXRef-Package-E36A5F0F0B31C3EA820F73BD5612ECBB45B859A2CFBC28719CDA2F76CC599F45",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.0.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/SkiaSharp.QrCode@1.0.0"
        }
      ],
      "supplier": "Organization: guitarrapc"
    },
    {
      "name": "Microsoft.NETCore.Platforms",
      "SPDXID": "SPDXRef-Package-846C7B671CE0E884005EF626B209AD4D24EBA1FF032B6BA0242D62EC1793AA97",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.1.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/Microsoft.NETCore.Platforms@1.1.0"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "PolySharp",
      "SPDXID": "SPDXRef-Package-E27E4A39B1271EC6E0119F8F3C1165DDB0F5493D9EB9DBC79586516D68EF6D27",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.15.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/PolySharp@1.15.0"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "System.Runtime.CompilerServices.Unsafe",
      "SPDXID": "SPDXRef-Package-C924E25709BAC7772763FD535719B02BFD9AE676EB73B3DA4C7058E9A501DC30",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "4.5.3",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/System.Runtime.CompilerServices.Unsafe@4.5.3"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "System.Numerics.Vectors",
      "SPDXID": "SPDXRef-Package-A3FB7F39F2BD82992BF718EC7C174635F4F24789BA41C19787B833205839923D",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "4.4.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/System.Numerics.Vectors@4.4.0"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "Microsoft.Sbom.Targets",
      "SPDXID": "SPDXRef-Package-187EFE2F1CB79DC28F70C62C1D8E0B6D840AF97A56D8D381EC4C4E7A968129CB",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "4.1.5",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/Microsoft.Sbom.Targets@4.1.5"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "System.Memory",
      "SPDXID": "SPDXRef-Package-A2645140C49BA822DFA302147B90231A2DD8246C825B2E5561535F2BFFD43192",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "4.5.5",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/System.Memory@4.5.5"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "SkiaSharp.NativeAssets.Win32",
      "SPDXID": "SPDXRef-Package-2B4D1AA306B3CA8ABA7A05C4A7C904C5E5001C8925991DA65BCC1D72239E7257",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "3.119.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/SkiaSharp.NativeAssets.Win32@3.119.1"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "SkiaSharp",
      "SPDXID": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "3.119.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/SkiaSharp@3.119.1"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "System.Buffers",
      "SPDXID": "SPDXRef-Package-CB3E43ED2FAAF926BACC8A3E5A5219246BD37049F5C72FD776C3144C1DBAB0A3",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "4.5.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/System.Buffers@4.5.1"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "NETStandard.Library",
      "SPDXID": "SPDXRef-Package-7FB8593FF8500851C8134780E1D4FFFA51D20C3770D3B4BBE0CE4C31BA6A9CE0",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "2.0.3",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/NETStandard.Library@2.0.3"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "SkiaSharp.NativeAssets.macOS",
      "SPDXID": "SPDXRef-Package-7337C35C8B5D91C470B5BE65044F09AEEB0C686397B7DDD632D96214A8BB0E77",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "3.119.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/SkiaSharp.NativeAssets.macOS@3.119.1"
        }
      ],
      "supplier": "NOASSERTION"
    },
    {
      "name": "SkiaSharp.QrCode",
      "SPDXID": "SPDXRef-RootPackage",
      "downloadLocation": "NOASSERTION",
      "packageVerificationCode": {
        "packageVerificationCodeValue": "7d8ab491469685618111f6c2a9d6a3cf51efcc6a"
      },
      "filesAnalyzed": true,
      "licenseConcluded": "NOASSERTION",
      "licenseInfoFromFiles": [
        "NOASSERTION"
      ],
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.0.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:swid/guitarrapc/spdx.org/SkiaSharp.QrCode@1.0.0?tag_id=bae17d65-a7f6-4eee-b220-1671b3afb182"
        }
      ],
      "supplier": "Organization: guitarrapc",
      "hasFiles": [
        "SPDXRef-File--lib-net10.0-SkiaSharp.QrCode.dll-99204E5B36AA5D3E01AB797497A43D3DE479B2A6",
        "SPDXRef-File---rels-.rels-9D0981B47DA2D0B8488D17207B3F4CC3F2C99E91",
        "SPDXRef-File--SkiaSharp.QrCode.nuspec-E7ABF4725060CDB3E4F6D0F981A6D650D559A7A7",
        "SPDXRef-File--lib-net8.0-SkiaSharp.QrCode.dll-A1F20BD93B1A73FE0D4F34E57FE5E83CF16E748C",
        "SPDXRef-File---Content-Types-.xml-231C3DFEA27B519CD97D24A63F7B8C8B99AD464E",
        "SPDXRef-File--README.md-41D1013908E6838C84BB5B113F0BAAEE042E1346",
        "SPDXRef-File--lib-netstandard2.1-SkiaSharp.QrCode.dll-0442B620957CC8FE998F26BD186AF7DBB6478C4A",
        "SPDXRef-File--package-services-metadata-core-properties-c74095c9b98b496db5bfeb7f0ec286d6.psmdcp-F931762800D5440480186EB37E0469B74BF2C8AF",
        "SPDXRef-File--lib-netstandard2.0-SkiaSharp.QrCode.dll-C1454A9087D55424B935EF97EA7128FBE6144C36"
      ]
    }
  ],
  "externalDocumentRefs": [],
  "relationships": [
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-2B4D1AA306B3CA8ABA7A05C4A7C904C5E5001C8925991DA65BCC1D72239E7257",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DESCRIBES",
      "relatedSpdxElement": "SPDXRef-RootPackage",
      "spdxElementId": "SPDXRef-DOCUMENT"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-7337C35C8B5D91C470B5BE65044F09AEEB0C686397B7DDD632D96214A8BB0E77",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-A2645140C49BA822DFA302147B90231A2DD8246C825B2E5561535F2BFFD43192",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-7FB8593FF8500851C8134780E1D4FFFA51D20C3770D3B4BBE0CE4C31BA6A9CE0",
      "spdxElementId": "SPDXRef-RootPackage"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-187EFE2F1CB79DC28F70C62C1D8E0B6D840AF97A56D8D381EC4C4E7A968129CB",
      "spdxElementId": "SPDXRef-RootPackage"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-A3FB7F39F2BD82992BF718EC7C174635F4F24789BA41C19787B833205839923D",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-A3FB7F39F2BD82992BF718EC7C174635F4F24789BA41C19787B833205839923D",
      "spdxElementId": "SPDXRef-Package-A2645140C49BA822DFA302147B90231A2DD8246C825B2E5561535F2BFFD43192"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-C924E25709BAC7772763FD535719B02BFD9AE676EB73B3DA4C7058E9A501DC30",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-C924E25709BAC7772763FD535719B02BFD9AE676EB73B3DA4C7058E9A501DC30",
      "spdxElementId": "SPDXRef-Package-A2645140C49BA822DFA302147B90231A2DD8246C825B2E5561535F2BFFD43192"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-E27E4A39B1271EC6E0119F8F3C1165DDB0F5493D9EB9DBC79586516D68EF6D27",
      "spdxElementId": "SPDXRef-RootPackage"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6",
      "spdxElementId": "SPDXRef-RootPackage"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-846C7B671CE0E884005EF626B209AD4D24EBA1FF032B6BA0242D62EC1793AA97",
      "spdxElementId": "SPDXRef-Package-7FB8593FF8500851C8134780E1D4FFFA51D20C3770D3B4BBE0CE4C31BA6A9CE0"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-CB3E43ED2FAAF926BACC8A3E5A5219246BD37049F5C72FD776C3144C1DBAB0A3",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-CB3E43ED2FAAF926BACC8A3E5A5219246BD37049F5C72FD776C3144C1DBAB0A3",
      "spdxElementId": "SPDXRef-Package-A2645140C49BA822DFA302147B90231A2DD8246C825B2E5561535F2BFFD43192"
    },
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-E36A5F0F0B31C3EA820F73BD5612ECBB45B859A2CFBC28719CDA2F76CC599F45",
      "spdxElementId": "SPDXRef-RootPackage"
    }
  ],
  "spdxVersion": "SPDX-2.2",
  "dataLicense": "CC0-1.0",
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "SkiaSharp.QrCode 1.0.0",
  "documentNamespace": "http://spdx.org/spdxdocs/SkiaSharp.QrCode/SkiaSharp.QrCode/1.0.0/JuLWYucADEOK3MtH3Acl-Q",
  "creationInfo": {
    "created": "2026-01-15T09:03:53Z",
    "creators": [
      "Organization: guitarrapc",
      "Tool: Microsoft.SBOMTool-4.1.5"
    ]
  },
  "documentDescribes": [
    "SPDXRef-RootPackage"
  ]
}

{
  "files": [
    {
      "fileName": "./lib/net8.0/SkiaSharp.QrCode.dll",
      "SPDXID": "SPDXRef-File--lib-net8.0-SkiaSharp.QrCode.dll-A1F20BD93B1A73FE0D4F34E57FE5E83CF16E748C",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "64884baa46df69ae1eed437e8a47e271399ffe215dad7577eb7106f0a429010f"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "a1f20bd93b1a73fe0d4f34e57fe5e83cf16e748c"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    {
      "fileName": "./lib/netstandard2.1/SkiaSharp.QrCode.dll",
      "SPDXID": "SPDXRef-File--lib-netstandard2.1-SkiaSharp.QrCode.dll-0442B620957CC8FE998F26BD186AF7DBB6478C4A",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "26c0489039142d6afd6fc6d65361f1d4b2160762571ed4f9283c311d02776069"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "0442b620957cc8fe998f26bd186af7dbb6478c4a"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    },
    // ... 省略
  ],
  "packages": [
    {
      "name": "SkiaSharp.QrCode",
      "SPDXID": "SPDXRef-Package-E36A5F0F0B31C3EA820F73BD5612ECBB45B859A2CFBC28719CDA2F76CC599F45",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.0.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/SkiaSharp.QrCode@1.0.0"
        }
      ],
      "supplier": "Organization: guitarrapc"
    },
    // ... 省略
    {
      "name": "SkiaSharp",
      "SPDXID": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "3.119.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/SkiaSharp@3.119.1"
        }
      ],
      "supplier": "NOASSERTION"
    },
    // ... 省略
    {
      "name": "SkiaSharp.QrCode",
      "SPDXID": "SPDXRef-RootPackage",
      "downloadLocation": "NOASSERTION",
      "packageVerificationCode": {
        "packageVerificationCodeValue": "7d8ab491469685618111f6c2a9d6a3cf51efcc6a"
      },
      "filesAnalyzed": true,
      "licenseConcluded": "NOASSERTION",
      "licenseInfoFromFiles": [
        "NOASSERTION"
      ],
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.0.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:swid/guitarrapc/spdx.org/SkiaSharp.QrCode@1.0.0?tag_id=bae17d65-a7f6-4eee-b220-1671b3afb182"
        }
      ],
      "supplier": "Organization: guitarrapc",
      "hasFiles": [
        "SPDXRef-File--lib-net10.0-SkiaSharp.QrCode.dll-99204E5B36AA5D3E01AB797497A43D3DE479B2A6",
        "SPDXRef-File---rels-.rels-9D0981B47DA2D0B8488D17207B3F4CC3F2C99E91",
        "SPDXRef-File--SkiaSharp.QrCode.nuspec-E7ABF4725060CDB3E4F6D0F981A6D650D559A7A7",
        "SPDXRef-File--lib-net8.0-SkiaSharp.QrCode.dll-A1F20BD93B1A73FE0D4F34E57FE5E83CF16E748C",
        "SPDXRef-File---Content-Types-.xml-231C3DFEA27B519CD97D24A63F7B8C8B99AD464E",
        "SPDXRef-File--README.md-41D1013908E6838C84BB5B113F0BAAEE042E1346",
        "SPDXRef-File--lib-netstandard2.1-SkiaSharp.QrCode.dll-0442B620957CC8FE998F26BD186AF7DBB6478C4A",
        "SPDXRef-File--package-services-metadata-core-properties-c74095c9b98b496db5bfeb7f0ec286d6.psmdcp-F931762800D5440480186EB37E0469B74BF2C8AF",
        "SPDXRef-File--lib-netstandard2.0-SkiaSharp.QrCode.dll-C1454A9087D55424B935EF97EA7128FBE6144C36"
      ]
    }
  ],
  "externalDocumentRefs": [],
  "relationships": [
    {
      "relationshipType": "DEPENDS_ON",
      "relatedSpdxElement": "SPDXRef-Package-2B4D1AA306B3CA8ABA7A05C4A7C904C5E5001C8925991DA65BCC1D72239E7257",
      "spdxElementId": "SPDXRef-Package-B4E80DA3E23BEEE3B1AAC22F912F2F4FF044AC3CF7DB457719854D84E12E9CB6"
    },
    {
      "relationshipType": "DESCRIBES",
      "relatedSpdxElement": "SPDXRef-RootPackage",
      "spdxElementId": "SPDXRef-DOCUMENT"
    },
    // ... 省略
  ],
  "spdxVersion": "SPDX-2.2",
  "dataLicense": "CC0-1.0",
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "SkiaSharp.QrCode 1.0.0",
  "documentNamespace": "http://spdx.org/spdxdocs/SkiaSharp.QrCode/SkiaSharp.QrCode/1.0.0/JuLWYucADEOK3MtH3Acl-Q",
  "creationInfo": {
    "created": "2026-01-15T09:03:53Z",
    "creators": [
      "Organization: guitarrapc",
      "Tool: Microsoft.SBOMTool-4.1.5"
    ]
  },
  "documentDescribes": [
    "SPDXRef-RootPackage"
  ]
}

既知の問題

SBOM生成してみて分かっている問題をまとめておきます。

Microsoft.Sbom.Targetsは外部コントリビュータを受け付けない

Microsoft.Sbom.Targetsは、セキュリティ的な理由から、外部コントリビュータを受け付けていないことが明言されています。

This project does not accept open-source contributions due to the sensitive, regulatory nature of SBOMs. If you are external to Microsoft and need modifications to the tool, you are welcome to fork and maintain a version of the tool.

Microsoft.Sbom.Targetsでライセンス情報を含められない

Microsoft.Sbom.Targetsは4.1.5時点では、NuGetパッケージのライセンス情報をSBOMに含めることができません。

本来、<SbomGenerationFetchLicenseInformation>SbomGenerationEnablePackageMetadataParsingtrueに設定すると、NuGetパッケージのライセンス情報をSBOMに含めることができます。ただ、現在この2つのオプションはパラメーターを渡し忘れているようでうまく動作していません。修正PRを作ったものの、前述の通り外部コントリビュータを受け付けないため、リポジトリオーナーのIssue修正対応待ちです。

ちなみにCLIであるsbom-toolではライセンス情報が出るので、最悪CLIで生成してdotnet packで含まれるようにするのも手です。sbom-toolをインストールして実行してみましょう。

いくつかの方法でインストールできるので、好きな方法を使ってください。

# Homebrew
$ brew install sbom-tool

# winget
$ winget install Microsoft.SbomTool

# dotnet tool
$ dotnet tool install -g Microsoft.Sbom.Tool

dotnetビルドしてから、そのパスに対して実行します。-li trueでライセンス情報を含め、-pm trueでパッケージメタデータを解析します。これでライセンス情報を含むSBOMが生成されます。

$ sbom-tool generate -b .\src\SkiaSharp.QrCode\bin\Release\net10.0 -bc . -pn SkiaSharp.QrCode -pv 0.13.0 -ps guitarrapc -li true -pm true
                                                   Detection Summary
┌─────────────────────────────┬─────────────────────────────┬─────────────────────────────┬────────────────────────────┐
│ Component Detector Id       │ Detection Time              │ # Components Found          │ # Explicitly Referenced    │
├─────────────────────────────┼─────────────────────────────┼─────────────────────────────┼────────────────────────────┤
│ CocoaPods                   │ 0.067 seconds               │ 0                           │ 0                          │
│ ConanLock                   │ 0.047 seconds               │ 0                           │ 0                          │
│ DotNet                      │ 1.1 seconds                 │ 11                          │ 0                          │
│ Go                          │ 0.066 seconds               │ 0                           │ 0                          │
│ Gradle                      │ 0.066 seconds               │ 0                           │ 0                          │
│ Ivy (Beta)                  │ 0.065 seconds               │ 0                           │ 0                          │
│ Linux                       │ 0.0019 seconds              │ 0                           │ 0                          │
│ LinuxApplicationLayer       │ 0.0019 seconds              │ 0                           │ 0                          │
│ (Beta)                      │                             │                             │                            │
│ MvnCli                      │ 0.046 seconds               │ 0                           │ 0                          │
│ Npm                         │ 0.047 seconds               │ 0                           │ 0                          │
│ NpmLockfile3                │ 0.047 seconds               │ 0                           │ 0                          │
│ NpmWithRoots                │ 0.047 seconds               │ 0                           │ 0                          │
│ NuGet                       │ 0.052 seconds               │ 1                           │ 0                          │
│ NuGetPackagesConfig         │ 0.047 seconds               │ 0                           │ 0                          │
│ NuGetProjectCentric         │ 0.41 seconds                │ 143                         │ 37                         │
│ PipReport                   │ 0.77 seconds                │ 0                           │ 0                          │
│ Pnpm                        │ 0.047 seconds               │ 0                           │ 0                          │
│ Poetry (Beta)               │ 0.047 seconds               │ 0                           │ 0                          │
│ Ruby                        │ 0.047 seconds               │ 0                           │ 0                          │
│ RustSbom                    │ 0.048 seconds               │ 0                           │ 0                          │
│ SPDX22SBOM                  │ 0.046 seconds               │ 0                           │ 0                          │
│ UvLock (Beta)               │ 0.047 seconds               │ 0                           │ 0                          │
│ Vcpkg                       │ 0.047 seconds               │ 0                           │ 0                          │
│ Yarn                        │ 0.047 seconds               │ 0                           │ 0                          │
│ ─────────────────────────── │ ─────────────────────────── │ ─────────────────────────── │ ────────────────────────── │
│ Total                       │ 1.1 seconds                 │ 155                         │ 37                         │
└─────────────────────────────┴─────────────────────────────┴─────────────────────────────┴────────────────────────────┘

$ ls -l ./src/SkiaSharp.QrCode/bin/Release/net10.0/_manifest/spdx_2.2
total 200
-rw-rw-r--    1 guitarrapc guitarrapc    199815 Jan 20 00:45 manifest.spdx.json
-rw-rw-r--    1 guitarrapc guitarrapc        64 Jan 20 00:45 manifest.spdx.json.sha256

生成されたmanifest.spdx.jsonのpackagesを確認すると、ライブラリのライセンス情報が含まれています。

{
  "files": [
    // ... 省略
  ],
  "packages": [
    {
      "name": "Microsoft.DotNet.ILCompiler",
      "SPDXID": "SPDXRef-Package-EC2544D7D5F9A1A227AAE004E2FFF4112D97161B3D683BEC01A80C8BE6AE3AB2",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "MIT", // <- ライセンス情報が含まれている
      "licenseDeclared": "MIT",  // <- ライセンス情報が含まれている
      "copyrightText": "NOASSERTION",
      "versionInfo": "10.0.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/Microsoft.DotNet.ILCompiler@10.0.1"
        }
      ],
      "supplier": "Organization: Microsoft"
    },
    {
      "name": "Microsoft.NET.ILLink.Tasks",
      "SPDXID": "SPDXRef-Package-4AA951B851143838491BE4038EAF79448F24E4A0D7870538FDCB0652BF421C54",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "MIT", // <- ライセンス情報が含まれている
      "licenseDeclared": "MIT",  // <- ライセンス情報が含まれている
      "copyrightText": "NOASSERTION",
      "versionInfo": "10.0.1",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:nuget/Microsoft.NET.ILLink.Tasks@10.0.1"
        }
      ],
      "supplier": "Organization: Microsoft"
    },
    // ... 省略
  ],
  // ... 省略
}

NuGetのSLSA対応状況

NuGetはSLSAに対応しておらず、NuGetパッケージ更新中にSLSA検証してくれません。Issueは立っているのですが、2026年1月時点では着手されていません。

GitHub自身はSLSAに対応しているので、GitHub Actionsでビルドしているならアテステーションは生成できます。ただ、後述するNuGetがnupkgに署名追加する問題を考えると、SLSAは今やっても特にメリットが生まれにくい状況です。

GitHubのSLSAを有効にする

参考までに、SkiaSharp.QrCodeでGitHub SLSAに対応しています。リリース用のワークフローが整備されているなら、permissionsとattestation生成ステップを追加するだけです。

SLSAの対応例

Workflow全文も出します。私はbuild-dotnetジョブでパッケージ生成 & アテステーション生成、create-releaseジョブでNuGetへのアップロード & GitHubリリース作成を行うようにしています。

name: release

on:
  push:
    tags:
      - "[0-9]+.[0-9]+.[0-9]+*" # only tag

jobs:
  build-dotnet:
    # 👇 これを追加
    permissions:
      attestations: write
      contents: read
      id-token: write
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
        with:
          persist-credentials: false
      - uses: guitarrapc/actions/.github/actions/setup-dotnet@main
        with:
          restore-wasm-workload: true
          dotnet-version: |
            10.0.x
            8.0.x
      # build
      - run: dotnet build -c Release -p:Version="${GIT_TAG}"
        env:
          GIT_TAG: ${{ github.ref_name }}
      # pack
      - run: dotnet pack -c Release -p:Version="${GIT_TAG}" -o ./publish
        env:
          GIT_TAG: ${{ github.ref_name }}

      # 👇 これを追加
      # attestations
      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
        with:
          subject-path: "./publish/*.nupkg"

      - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
        with:
          name: nuget
          path: ./publish/
          retention-days: 1
          if-no-files-found: error

  create-release:
    needs: [build-dotnet]
    permissions:
      contents: write
      id-token: write # for NuGet Trusted Publish
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: guitarrapc/actions/.github/actions/setup-dotnet@main
      # nuget
      - name: NuGet login (OIDC → temp API key)
        uses: NuGet/login@d22cc5f58ff5b88bf9bd452535b4335137e24544 # v1.1.0
        id: login
        with:
          user: ${{ secrets.SYNCED_NUGET_USER }}
      - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
        with:
          name: nuget
          path: ./nuget
      - name: List Nuget
        run: ls -al ./nuget
      # release
      - name: Create Release
        uses: guitarrapc/actions/.github/actions/create-release@main
        with:
          tag: ${{ github.ref_name }}
          title: ${{ github.ref_name }}
          gh-token: ${{ secrets.GITHUB_TOKEN }}
      # upload nuget
      - run: dotnet nuget push "./nuget/*.nupkg" --skip-duplicate -s https://api.nuget.org/v3/index.json -k "${NUGET_KEY}"
        env:
          NUGET_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}

具体的に説明します。

nupkgを生成するbuild-dotnetジョブにid-token: write権限を与えて署名できるようにし、attestations: write権限を与えてアテステーションを生成します。もしReusable Workflowsを使っているなら、呼び出し側ワークフローと、Reusable Workflow両方で権限を設定してください。

jobs:
  build-dotnet:
    permissions:
      attestations: write
      contents: read
      id-token: write

パッケージ改ざんされていないことを保証するため、dotnet pack直後にactions/attest-build-provenanceアクションでアテステーションを生成します。.nupkgのパスをglob指定できます。

# attestations
- name: Generate artifact attestation
  uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
  with:
    subject-path: "./publish/*.nupkg"

あとは、GitHubがアテステーションを生成して、いい感じにしてくれます。GitHub ActionsのステップサマリにAttestationへのリンクが表示されます。

ジョブサマリーにAttestationへのリンクが表示される

また、アクション一覧ページからもAttestationsへのリンクが表示されます。

Actions一覧にAttestationsへのリンク

アテステーションはの表示です。

Attestationの表示

ダウンロードするとJSON形式で取得できます。

{
  "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
  "verificationMaterial": {
    "tlogEntries": [
      {
        "logIndex": "835217970",
        "logId": {
          "keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="
        },
        "kindVersion": {
          "kind": "dsse",
          "version": "0.0.1"
        },
        "integratedTime": "1768840664",
        "inclusionPromise": {
          "signedEntryTimestamp": "MEYCIQCWAvjFytP+rZXykjvnDsvfTCYZ3eg4WY60TX2EouvptwIhAOArdsnifSjU4BWzC1PwSVFytCk1zcsF6csOZ1FGJhNp"
        },
        "inclusionProof": {
          "logIndex": "713313708",
          "rootHash": "C+YXcG3cfQl9Nt5NhgJb9/a+STS3CQpNGClct3Eg7s0=",
          "treeSize": "713313726",
          "hashes": [
            "isMZTB4pijmD9DhEMUNnGx7LrUX4/3xE22jlto9feQk=",
            "YFb8sqG+Xxbtj3RnT9JWSUweX/30likZ1dhVACCFurU=",
            "D5KzRFYiGngedRCyw2X6YkRI3LmCNLjQH+O1l1sIYJE=",
            "vs1Uz3jRDfkBxuy6zMgRYab9PcDg7++K9iNNHauUlFQ=",
            "0ZzQ4h4W64Ag0vIijb5LPEhhAa7SL8ymU8Lrk0Zj9XM=",
            "NTlN3nIekisb3pmZCH8rrM460Rs1bcJQH1JpdPGmG2o=",
            "3JqUIuWn4UBv2aMZw5lUXv8g6CUqPxu7KWUcBvzAl1s=",
            "RwTGvP9HH06LMfaBZeMYp1fIGDGMg0dbGaZvyt1L0/Y=",
            "EjIUBtrmRy2IqbdKc5Ke7PZKXF3jsG1NMZffwO/GNcw=",
            "ShqwFMnR5RIJ0f9oW9JBTNns25EHyq+5HGjupP/P2Rg=",
            "FdXCR6D6osRQQrTQbKq2kg4TCNotPROY6hrjqR3oFJE=",
            "Da7pWtfttPyV5553iZ/1ojqTqxzHV1f8OS/kCXEaSIc=",
            "vk+Sc7c1laTnH9uCSqZ0Un3rutG4UGrLDkm3cECOZUU=",
            "WFmkMhmL2tOzDi6lp4zGgaCzwux2vGOM44v1vr1wuDs=",
            "F9MSQ5SmoFr+hoADclpdFY52/TLfHDnNPYb9ZNYO5gI=",
            "T4DqWD42hAtN+vX8jKCWqoC4meE4JekI9LxYGCcPy1M="
          ],
          "checkpoint": {
            "envelope": "rekor.sigstore.dev - 1193050959916656506\n713313726\nC+YXcG3cfQl9Nt5NhgJb9/a+STS3CQpNGClct3Eg7s0=\n\n— rekor.sigstore.dev wNI9ajBFAiADtpkKBajYEBDRCC5GyLd3P9mEhPXGSx27O9n8YVrVrgIhAIV0oFk+yvFnoQxLtmaVkZhKMJPg1HnKGJr6Xj04IWfg\n"
          }
        },
        "canonicalizedBody": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiZHNzZSIsInNwZWMiOnsiZW52ZWxvcGVIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiYjI4OGVmY2NhODVkZjFhZjhhZThiNjU5MzI1NjFlZjIzOTVmM2UxNWNmNWM1NjNkNDEwM2QxMjQ5Njg0OGM1MSJ9LCJwYXlsb2FkSGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImQzYjlkNjg4ZTNjM2EwMjdlYmFjZDk3ZjJjMTIwMTFkOWNkMTFiOTI1OWVjYjAwMDFlZWM5NzE2OTBhYzE3MzIifSwic2lnbmF0dXJlcyI6W3sic2lnbmF0dXJlIjoiTUVRQ0lETGpQK0RuYnhiajVZem1rWXpGZ2hGcktNQ3NIRVAxamNTZnNBL3B1WXdZQWlCVGxGVlRhcVZreFBXZjJvMkNNUTZwaGJ0MDJ5TVh1cERkTlBsTHFhc2Nzdz09IiwidmVyaWZpZXIiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VjdlZFTkRRbTlUWjBGM1NVSkJaMGxWUTNNd1NtMU1LeTl3ZHpGcGIwZHlUR2h5UVhCQmEzSmhZemRaZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwWmQwMVVSVFZOVkZsNlRucFJNRmRvWTA1TmFsbDNUVlJGTlUxVVdUQk9lbEV3VjJwQlFVMUdhM2RGZDFsSUNrdHZXa2w2YWpCRFFWRlpTVXR2V2tsNmFqQkVRVkZqUkZGblFVVnNWR2xLSzFFeFYwRlBXVEZLVFVwWlpHZHNlRWQwT0dZeWRFMTVRVGhzT0dWcGN6UUtiV1ZKYzNWaVNtNU1PWGhFUldSQ2VtMDVURkZMWkhOb2MxWTFhR0ZLU21GTGJGa3JUblZFYTI5a016RnhOMFpCSzJGUFEwSmhUWGRuWjFkbVRVRTBSd3BCTVZWa1JIZEZRaTkzVVVWQmQwbElaMFJCVkVKblRsWklVMVZGUkVSQlMwSm5aM0pDWjBWR1FsRmpSRUY2UVdSQ1owNVdTRkUwUlVablVWVkRURlF3Q2k5S1JYRXhkVTFCWkc5dUx5OUZXR3RuVlVOcFZYUkpkMGgzV1VSV1VqQnFRa0puZDBadlFWVXpPVkJ3ZWpGWmEwVmFZalZ4VG1wd1MwWlhhWGhwTkZrS1drUTRkMkYzV1VSV1VqQlNRVkZJTDBKSFJYZFlORnBrWVVoU01HTklUVFpNZVRsdVlWaFNiMlJYU1hWWk1qbDBUREprTVdGWVVtaGpia3BvWTBkTmRncFZNblJ3V1ZaT2IxbFlTbmRNYkVaNVVUSTVhMXBUT0hWYU1td3dZVWhXYVV3elpIWmpiWFJ0WWtjNU0yTjVPWGxhVjNoc1dWaE9iRXh1YkdoaVYzaEJDbU50Vm0xamVUbHZXbGRHYTJONU9YUlpWMngxVFVSclIwTnBjMGRCVVZGQ1p6YzRkMEZSUlVWTE1tZ3daRWhDZWs5cE9IWmtSemx5V2xjMGRWbFhUakFLWVZjNWRXTjVOVzVoV0ZKdlpGZEtNV015Vm5sWk1qbDFaRWRXZFdSRE5XcGlNakIzU0hkWlMwdDNXVUpDUVVkRWRucEJRa0ZuVVZKa01qbDVZVEphY3dwaU0yUm1Xa2RzZW1OSFJqQlpNbWQzVG1kWlMwdDNXVUpDUVVkRWRucEJRa0YzVVc5YWFrRXpUbFJXYTAxRVVYbFBWMFUxVFVSa2EwNXFXVE5QVjFsNkNscFhVWGxOZWxGNFRYcG9hRTlIV1hoWmVtUnNUVVJqZWxsVVFWWkNaMjl5UW1kRlJVRlpUeTlOUVVWRlFrRmtlVnBYZUd4WldFNXNUVU5yUjBOcGMwY0tRVkZSUW1jM09IZEJVVlZGUnpKa01XRllVbWhqYmtwb1kwZE5kbFV5ZEhCWlZrNXZXVmhLZDB4c1JubFJNamxyV2xSQlpFSm5iM0pDWjBWRlFWbFBMd3BOUVVWSFFrRTVlVnBYV25wTU1taHNXVmRTZWt3eU1XaGhWelIzVDNkWlMwdDNXVUpDUVVkRWRucEJRa05CVVhSRVEzUnZaRWhTZDJONmIzWk1NMUoyQ21FeVZuVk1iVVpxWkVkc2RtSnVUWFZhTW13d1lVaFdhV1JZVG14amJVNTJZbTVTYkdKdVVYVlpNamwwVFVjd1IwTnBjMGRCVVZGQ1p6YzRkMEZSYTBVS1dIZDRaR0ZJVWpCalNFMDJUSGs1Ym1GWVVtOWtWMGwxV1RJNWRFd3laREZoV0ZKb1kyNUthR05IVFhaVk1uUndXVlpPYjFsWVNuZE1iRVo1VVRJNWF3cGFVemgxV2pKc01HRklWbWxNTTJSMlkyMTBiV0pIT1ROamVUbDVXbGQ0YkZsWVRteE1ibXhvWWxkNFFXTnRWbTFqZVRsdldsZEdhMk41T1hSWlYyeDFDazFFWjBkRGFYTkhRVkZSUW1jM09IZEJVVzlGUzJkM2IxcHFRVE5PVkZaclRVUlJlVTlYUlRWTlJHUnJUbXBaTTA5WFdYcGFWMUY1VFhwUmVFMTZhR2dLVDBkWmVGbDZaR3hOUkdONldWUkJaRUpuYjNKQ1owVkZRVmxQTDAxQlJVeENRVGhOUkZka2NHUkhhREZaYVRGdllqTk9NRnBYVVhkUVoxbExTM2RaUWdwQ1FVZEVkbnBCUWtSQlVYZEVRelZ2WkVoU2QyTjZiM1pNTW1Sd1pFZG9NVmxwTldwaU1qQjJXak5XY0dSSFJubGpiVVozV1hrNVZHRXliR2hWTW1ob0NtTnVRWFZWV0VwRVlqSlNiRTFFWjBkRGFYTkhRVkZSUW1jM09IZEJVVEJGUzJkM2IxcHFRVE5PVkZaclRVUlJlVTlYUlRWTlJHUnJUbXBaTTA5WFdYb0tXbGRSZVUxNlVYaE5lbWhvVDBkWmVGbDZaR3hOUkdONldWUkJaa0puYjNKQ1owVkZRVmxQTDAxQlJVOUNRa1ZOUkROS2JGcHVUWFpoUjFab1draE5kZ3BpVjBad1ltcEJXa0puYjNKQ1owVkZRVmxQTDAxQlJWQkNRWE5OUTFSRk1rNXFRWGxQVkdNeFRYcEJkRUpuYjNKQ1owVkZRVmxQTDAxQlJWRkNRamhOQ2toWGFEQmtTRUo2VDJrNGRsb3liREJoU0ZacFRHMU9kbUpUT1c1a1Yyd3dXVmhLZVZsWVFtcE5RbU5IUTJselIwRlJVVUpuTnpoM1FWSkZSVU5SZDBnS1RYcG5NVTVxVFRGTlJFSjBRbWR2Y2tKblJVVkJXVTh2VFVGRlUwSkdPRTFZVjJnd1pFaENlazlwT0haYU1td3dZVWhXYVV4dFRuWmlVemx1WkZkc01BcFpXRXA1V1ZoQ2Frd3hUbkpoVjBaVVlVZEdlV05ETlZKamEwNTJXa2RWZGt4dFpIQmtSMmd4V1drNU0ySXpTbkphYlhoMlpETk5kbU50Vm5OYVYwWjZDbHBUTlRWWlZ6RnpVVWhLYkZwdVRYWmhSMVpvV2toTmRtSlhSbkJpYWtFMFFtZHZja0puUlVWQldVOHZUVUZGVkVKRGIwMUxSMWwzVG5wVk1WcEVRVEFLVFdwc2FFOVVRVE5hUkZreVRucHNiVTB5Vm10TmFrMHdUVlJOTkZsVWFHMU5WMDB6V2xSQk0wMHlSWGRKVVZsTFMzZFpRa0pCUjBSMmVrRkNSa0ZSVkFwRVFrWXpZak5LY2xwdGVIWmtNVGxyWVZoT2QxbFlVbXBoUkVKcFFtZHZja0puUlVWQldVOHZUVUZGVmtKR1VVMVZiV2d3WkVoQ2VrOXBPSFphTW13d0NtRklWbWxNYlU1MllsTTVibVJYYkRCWldFcDVXVmhDYWt3eFRuSmhWMFpVWVVkR2VXTkROVkpqYTA1MldrZFZkbGxYVGpCaFZ6bDFZM2s1ZVdSWE5Yb0tUSHBKZUUxVVVURk5SRkY0VDFSRmVVd3lSakJrUjFaMFkwaFNla3g2UlhkR1oxbExTM2RaUWtKQlIwUjJla0ZDUm1kUlNVUkJXbmRrVjBwellWZE5kd3BuV1c5SFEybHpSMEZSVVVJeGJtdERRa0ZKUldaQlVqWkJTR2RCWkdkRVpGQlVRbkY0YzJOU1RXMU5Xa2hvZVZwYWVtTkRiMnR3WlhWT05EaHlaaXRJQ21sdVMwRk1lVzUxYW1kQlFVRmFkbGhJY0ZrMlFVRkJSVUYzUWtoTlJWVkRTVUl3T1ZoTGVGSnFjVEp4UldVck9HOWtWV1ZsWkVOT2RXRm9VV0pZV2s4S00xRk5UVmxIYkRSWGFHNVpRV2xGUVRSUWRFVlBiRkZKYmtoRmRGSnBUVVozUzFsRVdVeE1Nemd3VVcxak9XVkhNWFUzY0M5S01VTllSemgzUTJkWlNRcExiMXBKZW1vd1JVRjNUVVJhZDBGM1drRkpkME5MZURoaGRtdExjbFpXVFdKWWNGTk9TbU5NYzNGdllsRkNPUzlTTm5jNGJqaDVSeXRNTkZGRWMySlZDbGsxWkN0blkweHlSV3hQVjFWSVRXcG1kRXBSUVdwQlFuWmpaMlZhWkc1alJrMWljVGM0VERkYVRqazBiSEVyZFhkc2IxSnViV1JyWlROclEwOVVRbFlLUzJwT1RVWjFaMGx5WVhrdmFVWTViM0ZyTkdabWR6UTlDaTB0TFMwdFJVNUVJRU5GVWxSSlJrbERRVlJGTFMwdExTMEsifV19fQ=="
      }
    ],
    "timestampVerificationData": {},
    "certificate": {
      "rawBytes": "MIIG/TCCBoSgAwIBAgIUCs0JmL+/pw1ioGrLhrApAkrac7YwCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwMTE5MTYzNzQ0WhcNMjYwMTE5MTY0NzQ0WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElTiJ+Q1WAOY1JMJYdglxGt8f2tMyA8l8eis4meIsubJnL9xDEdBzm9LQKdshsV5haJJaKlY+NuDkod31q7FA+aOCBaMwggWfMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUCLT0/JEq1uMAdon//EXkgUCiUtIwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wawYDVR0RAQH/BGEwX4ZdaHR0cHM6Ly9naXRodWIuY29tL2d1aXRhcnJhcGMvU2tpYVNoYXJwLlFyQ29kZS8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnlhbWxAcmVmcy9oZWFkcy9tYWluMDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wHwYKKwYBBAGDvzABAgQRd29ya2Zsb3dfZGlzcGF0Y2gwNgYKKwYBBAGDvzABAwQoZjA3NTVkMDQyOWE5MDdkNjY3OWYzZWQyMzQxMzhhOGYxYzdlMDczYTAVBgorBgEEAYO/MAEEBAdyZWxlYXNlMCkGCisGAQQBg78wAQUEG2d1aXRhcnJhcGMvU2tpYVNoYXJwLlFyQ29kZTAdBgorBgEEAYO/MAEGBA9yZWZzL2hlYWRzL21haW4wOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMG0GCisGAQQBg78wAQkEXwxdaHR0cHM6Ly9naXRodWIuY29tL2d1aXRhcnJhcGMvU2tpYVNoYXJwLlFyQ29kZS8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnlhbWxAcmVmcy9oZWFkcy9tYWluMDgGCisGAQQBg78wAQoEKgwoZjA3NTVkMDQyOWE5MDdkNjY3OWYzZWQyMzQxMzhhOGYxYzdlMDczYTAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwPgYKKwYBBAGDvzABDAQwDC5odHRwczovL2dpdGh1Yi5jb20vZ3VpdGFycmFwYy9Ta2lhU2hhcnAuUXJDb2RlMDgGCisGAQQBg78wAQ0EKgwoZjA3NTVkMDQyOWE5MDdkNjY3OWYzZWQyMzQxMzhhOGYxYzdlMDczYTAfBgorBgEEAYO/MAEOBBEMD3JlZnMvaGVhZHMvbWFpbjAZBgorBgEEAYO/MAEPBAsMCTE2NjAyOTc1MzAtBgorBgEEAYO/MAEQBB8MHWh0dHBzOi8vZ2l0aHViLmNvbS9ndWl0YXJyYXBjMBcGCisGAQQBg78wAREECQwHMzg1NjM1MDBtBgorBgEEAYO/MAESBF8MXWh0dHBzOi8vZ2l0aHViLmNvbS9ndWl0YXJyYXBjL1NraWFTaGFycC5RckNvZGUvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55YW1sQHJlZnMvaGVhZHMvbWFpbjA4BgorBgEEAYO/MAETBCoMKGYwNzU1ZDA0MjlhOTA3ZDY2NzlmM2VkMjM0MTM4YThmMWM3ZTA3M2EwIQYKKwYBBAGDvzABFAQTDBF3b3JrZmxvd19kaXNwYXRjaDBiBgorBgEEAYO/MAEVBFQMUmh0dHBzOi8vZ2l0aHViLmNvbS9ndWl0YXJyYXBjL1NraWFTaGFycC5RckNvZGUvYWN0aW9ucy9ydW5zLzIxMTQ1MDQxOTEyL2F0dGVtcHRzLzEwFgYKKwYBBAGDvzABFgQIDAZwdWJsaWMwgYoGCisGAQQB1nkCBAIEfAR6AHgAdgDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZvXHpY6AAAEAwBHMEUCIB09XKxRjq2qEe+8odUeedCNuahQbXZO3QMMYGl4WhnYAiEA4PtEOlQInHEtRiMFwKYDYLL380Qmc9eG1u7p/J1CXG8wCgYIKoZIzj0EAwMDZwAwZAIwCKx8avkKrVVMbXpSNJcLsqobQB9/R6w8n8yG+L4QDsbUY5d+gcLrElOWUHMjftJQAjABvcgeZdncFMbq78L7ZN94lq+uwloRnmdke3kCOTBVKjNMFugIray/iF9oqk4ffw4="
    }
  },
  "dsseEnvelope": {
    "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJzdWJqZWN0IjpbeyJuYW1lIjoiU2tpYVNoYXJwLlFyQ29kZS4wLjAuMC1kZXYubnVwa2ciLCJkaWdlc3QiOnsic2hhMjU2IjoiZGQzNDEyZWVhZTM2MzgwOGViM2M0YTNmOGZkMzI2YThmMTM2ZDE5NGM4YzZmYmU0NDU1OTc1Yjc3NGQ4OWU3ZiJ9fV0sInByZWRpY2F0ZVR5cGUiOiJodHRwczovL3Nsc2EuZGV2L3Byb3ZlbmFuY2UvdjEiLCJwcmVkaWNhdGUiOnsiYnVpbGREZWZpbml0aW9uIjp7ImJ1aWxkVHlwZSI6Imh0dHBzOi8vYWN0aW9ucy5naXRodWIuaW8vYnVpbGR0eXBlcy93b3JrZmxvdy92MSIsImV4dGVybmFsUGFyYW1ldGVycyI6eyJ3b3JrZmxvdyI6eyJyZWYiOiJyZWZzL2hlYWRzL21haW4iLCJyZXBvc2l0b3J5IjoiaHR0cHM6Ly9naXRodWIuY29tL2d1aXRhcnJhcGMvU2tpYVNoYXJwLlFyQ29kZSIsInBhdGgiOiIuZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnlhbWwifX0sImludGVybmFsUGFyYW1ldGVycyI6eyJnaXRodWIiOnsiZXZlbnRfbmFtZSI6IndvcmtmbG93X2Rpc3BhdGNoIiwicmVwb3NpdG9yeV9pZCI6IjE2NjAyOTc1MyIsInJlcG9zaXRvcnlfb3duZXJfaWQiOiIzODU2MzUwIiwicnVubmVyX2Vudmlyb25tZW50IjoiZ2l0aHViLWhvc3RlZCJ9fSwicmVzb2x2ZWREZXBlbmRlbmNpZXMiOlt7InVyaSI6ImdpdCtodHRwczovL2dpdGh1Yi5jb20vZ3VpdGFycmFwYy9Ta2lhU2hhcnAuUXJDb2RlQHJlZnMvaGVhZHMvbWFpbiIsImRpZ2VzdCI6eyJnaXRDb21taXQiOiJmMDc1NWQwNDI5YTkwN2Q2Njc5ZjNlZDIzNDEzOGE4ZjFjN2UwNzNhIn19XX0sInJ1bkRldGFpbHMiOnsiYnVpbGRlciI6eyJpZCI6Imh0dHBzOi8vZ2l0aHViLmNvbS9ndWl0YXJyYXBjL1NraWFTaGFycC5RckNvZGUvLmdpdGh1Yi93b3JrZmxvd3MvcmVsZWFzZS55YW1sQHJlZnMvaGVhZHMvbWFpbiJ9LCJtZXRhZGF0YSI6eyJpbnZvY2F0aW9uSWQiOiJodHRwczovL2dpdGh1Yi5jb20vZ3VpdGFycmFwYy9Ta2lhU2hhcnAuUXJDb2RlL2FjdGlvbnMvcnVucy8yMTE0NTA0MTkxMi9hdHRlbXB0cy8xIn19fX0=",
    "payloadType": "application/vnd.in-toto+json",
    "signatures": [
      {
        "sig": "MEQCIDLjP+Dnbxbj5YzmkYzFghFrKMCsHEP1jcSfsA/puYwYAiBTlFVTaqVkxPWf2o2CMQ6phbt02yMXupDdNPlLqascsw=="
      }
    ]
  }
}

nupkgのSLSAを検証する

SLSAの検証を確認しましょう。slsa-verifierなどありますが、ghを使うのが一番簡単でしょう。ghの場合、gh attestation verifyコマンドで検証できます。

gh attestation verify <path/to/artifact/to/verify> -R <org/repo>

例えば、SkiaSharp.QrCodeのdevパッケージをGitHub Actionsで生成、生成したnupkgをダウンロードして検証してみましょう。

$ gh attestation verify --repo guitarrapc/SkiaSharp.QrCode "SkiaSharp.QrCode.0.0.0-dev.nupkg"
Loaded digest sha256:dd3412eeae363808eb3c4a3f8fd326a8f136d194c8c6fbe4455975b774d89e7f for file://SkiaSharp.QrCode.0.0.0-dev.nupkg
Loaded 1 attestation from GitHub API

The following policy criteria will be enforced:
- Predicate type must match:................ https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:... https://github.com/guitarrapc
- Source Repository URI must match:......... https://github.com/guitarrapc/SkiaSharp.QrCode
- Subject Alternative Name must match regex: (?i)^https://github.com/guitarrapc/SkiaSharp.QrCode/
- OIDC Issuer must match:................... https://token.actions.githubusercontent.com

✓ Verification succeeded!

The following 1 attestation matched the policy criteria

- Attestation #1
  - Build repo:..... guitarrapc/SkiaSharp.QrCode
  - Build workflow:. .github/workflows/release.yaml@refs/heads/main
  - Signer repo:.... guitarrapc/SkiaSharp.QrCode
  - Signer workflow: .github/workflows/release.yaml@refs/heads/main

GitHub Actionsで生成したものなので、アテステーションと一致しており検証が成功しました。

もしローカルのnupkgを検証すると該当するアテステーションが失敗します。これで、ローカルでビルドしたnupkgをあたかもリリースパッケージのように見せかけても、SLSA検証に失敗することがわかります。

$ gh attestation verify --repo guitarrapc/SkiaSharp.QrCode "./src/SkiaSharp.QrCode/bin/Release/SkiaSharp.QrCode.1.0.0.nupkg"
Loaded digest sha256:aa2f5f3c67d7b5f09839a388cff25da7c5a22e3ecf68cb22d4d4f190198c70e0 for file://src\SkiaSharp.QrCode\bin\Release\SkiaSharp.QrCode.1.0.0.nupkg
✗ Loading attestations from GitHub API failed

Error: failed to fetch attestations from guitarrapc/SkiaSharp.QrCode: HTTP 404: Not Found (https://api.github.com/repos/guitarrapc/SkiaSharp.QrCode/attestations/sha256:aa2f5f3c67d7b5f09839a388cff25da7c5a22e3ecf68cb22d4d4f190198c70e0?per_page=30)

NuGetが.nupkgに署名する問題

せっかく作ったGitHub SLSAですが、NuGetはアップロードされたパッケージに対して署名するためアップロードした.nupkgファイルとNuGetサーバーからダウンロードした.nupkgファイルは異なります2。ということは、ビルド用に生成したSLSA Attestationと一致せずgh attestation verify検証が失敗します。

NuGetサーバーからダウンロードした.nupkgファイルの中身を確認してみましょう。中に.signature.p7sが含まれているのがわかります。

$ curl -L -o SkiaSharp.QrCode.0.12.0.nupkg "https://www.nuget.org/api/v2/package/SkiaSharp.QrCode/0.12.0"
$ unzip -l skiasharp.qrcode.0.12.0.nupkg
Archive:  skiasharp.qrcode.0.12.0.nupkg
  Length      Date    Time    Name
---------  ---------- -----   ----
      506  28/11/2025 14:24   _rels/.rels
     2176  28/11/2025 14:24   SkiaSharp.QrCode.nuspec
    85504  28/11/2025 14:24   lib/net10.0/SkiaSharp.QrCode.dll
    86016  28/11/2025 14:24   lib/net8.0/SkiaSharp.QrCode.dll
    91648  28/11/2025 14:24   lib/netstandard2.0/SkiaSharp.QrCode.dll
    87552  28/11/2025 14:24   lib/netstandard2.1/SkiaSharp.QrCode.dll
    33143  28/11/2025 14:23   README.md
      520  28/11/2025 14:24   [Content_Types].xml
      687  28/11/2025 14:24   package/services/metadata/core-properties/d303c5891bb440bba97102727f1ff08b.psmdcp
    12984  28/11/2025 06:27   .signature.p7s
---------                     -------
   400736                     10 files

dotnet packで生成したnupkgファイルには.signature.p7sは含まれていません。

unzip -l SkiaSharp.QrCode.0.0.0-dev.nupkg
Archive:  SkiaSharp.QrCode.0.0.0-dev.nupkg
  Length      Date    Time    Name
---------  ---------- -----   ----
      520  19/01/2026 16:37   [Content_Types].xml
     2179  19/01/2026 16:37   SkiaSharp.QrCode.nuspec
    33143  19/01/2026 16:36   README.md
      506  19/01/2026 16:37   _rels/.rels
    86016  19/01/2026 16:37   lib/net8.0/SkiaSharp.QrCode.dll
    87552  19/01/2026 16:37   lib/netstandard2.1/SkiaSharp.QrCode.dll
    91648  19/01/2026 16:37   lib/netstandard2.0/SkiaSharp.QrCode.dll
    85504  19/01/2026 16:37   lib/net10.0/SkiaSharp.QrCode.dll
    19077  19/01/2026 16:37   _manifest/spdx_2.2/manifest.spdx.json
       64  19/01/2026 16:37   _manifest/spdx_2.2/manifest.spdx.json.sha256
      690  19/01/2026 16:37   package/services/metadata/core-properties/be58e68591294e81841f7c0e290b7f4f.psmdcp
---------                     -------
   406899                     11 files

この問題はIssueで報告されていますが、NuGet側の対応はちょっとどうするのか読めない状況です。

ワークアラウンドしては、NuGetサーバーからダウンロードしたnupkgファイルの.signature.p7sを削除してからSLSA検証します。これはNixOSがやっている方法です。

    runCommand src.name
      {
        inherit src;
        nativeBuildInputs = [ zip ];
      }
      ''
        zip "$src" --temp-path "$TMPDIR" --output-file "$out" --delete .signature.p7s || {
          (( $? == 12 ))
          install -Dm644 "$src" "$out"
        }
      '';

コマンドでやるなら次のようになります。

# まだリリースされていないですが...
$ curl -L -o SkiaSharp.QrCode.0.0.13.nupkg "https://www.nuget.org/api/v2/package/SkiaSharp.QrCode/0.13.0"
$ zip -d SkiaSharp.QrCode.0.0.13.nupkg .signature.p7s

# その後でSLSA検証
$ gh attestation verify --owner guitarrapc "SkiaSharp.QrCode.0.0.13.nupkg"

いずれにしても、NuGetクライアントのワークフロー的にはSLSA検証は機能しないですし、この署名は根深そうに見えます。

正直、NuGetがアップロードされた.nupkgに署名ファイルを追加しているのはちょっと危なそうにも見えます。署名ファイルだけで信頼されてしまうと、.nupkgのアップロード過程を攻撃されると、悪意のあるコードを含む.nupkgが署名付きで配布されてしまうリスクがありそうですだからです。SLSAのようなビルドの信頼性を確保する仕組みと矛盾しているように見えるので、将来どうするのか注目しています。

Unity

Unityは特にSBOMやSLSAには対応していません。ディスカッションは上がっていますが、Adminのコメントを見る限り現時点では特に対応予定はないようです。

Unity 6.3以降、tarballに署名して組織で共有できるようになりますが、これはSBOM/SLSAとは別の仕組みです。

まとめ

C#的には、SBOMは今から対応しておいても良いですが、SLSAはNuGetの対応を待つのが賢明です。Unityは無視でOKです。

C#

SBOMは組み込んでもいいでしょう。Microsoft.Sbom.Targetsを入れるとnupkgにsbom含められるので意識せずに担保できます。今は、ライセンス出力周りが機能していないので修正待ちです。

SLSAは今やっても特にメリットが生まれにくい状況です。NuGetの対応を待つのが賢明です。GitHubのアテステーションは生成できますが、実用性は低いです。

Unity

現時点では、SBOM・SLSAの仕組みは提供されていません。無視でOK。

参考

対応参考

NuGet/Unity Issue

GitHub

NuGet

Blog


  1. デフォルトのSPDXバージョンが2.2なのでパスはspdx_2.2ですが、3.0に変更するとパスはspdx_3.0になります。
  2. この署名はNuGetクライアントがパッケージの整合性を検証するために使われます。

OSS開発者にとってのSBOMとSLSAの状況

ソフトウェアのサプライチェインを担保する手段としてSBOMとSLSAがあります。 SBOM(Software Bill of Materials)は、そのソフトウェアの構成要素をリスト化したもの、SLSA(Supply chain Levels for Software Artifacts)は、ソフトウェア成果物の工程がどの程度信頼できるかを段階的に定義したフレームワークです。

今回は、2026年1月時点におけるSBOMとSLSAの状況について調べたことのメモです。

モチベーション

ここ数年OSSソフトウェアを利用した攻撃の1つとしてサプライチェイン攻撃を見かける頻度が上がっています。攻撃の最終目標は仮想通貨プラットフォームへの侵入だったりするようですが、OSSソフトウェアは他のOSSソフトウェアに依存していることが多いという性質から、攻撃者はその依存関係を悪用して攻撃を仕掛けることができます。

OSSライブラリ作者として自分が開発しているOSSライブラリがサプライチェイン攻撃に巻き込まれた際、その影響を早く確認し、また利用者に正当なライブラリであることを証明するのに役立つのがSBOMとSLSAです。これらを組み合わせることで、そもそもライブラリに影響しているのか確認しやすくし、またライブラリ利用者が正当なライブラリか検証しやすくなります。

今回の調査は、今後OSSライブラリ回りでSBOMやSLSAが要請されるようになる可能性もあるため、それに備えて現状のSBOMとSLSAの状況を調べておくのを動機としています。この調査を元に、次回はC#ライブラリにおけるSBOMとSLSAについてみていきます。

サプライチェインとSBOMとSLSA

ソフトウェアのサプライチェインは2つの視点があります。SBOMとSLSAはそれぞれ別領域で、サプライチェインという視点では両者が揃うことで、ソフトウェアの信頼性を高めることができます。

  1. そのソフトウェアは何でできているのか。構成しているソフトウェアは何なのか部品を明確にする -> SBOM
  2. そのソフトウェアがどのように作られたか(provenance)を、アテステーションなど検証可能な形で示す -> SLSA

つまりSBOMで何が入っているかをリストアップし、ハッシュで改ざんされてないか、SLSAのアテステーションで「そのハッシュが正規のビルド工程から生成されたという主張」を署名付きで提供するイメージです。これを提供したいかどうかが判断基準になります。

SBOMの例としてSPDX2.2フォーマットの一例を見てみましょう。filesに配布時含まれるファイル群、packagesにソフトウェアを構成するパッケージ群が記載されます。NOASSERTIONはSBOM発行時に指定しないなどの理由で情報がない場合に使われます。なるほど、確かにSBOMを見れば、そのソフトウェアが何でできているのか把握できます。

{
  // 含まれるファイル群
  "files": [
    {
      "fileName": "./lib/libfoo.so",
      "SPDXID": "SPDXRef-File--lib-Example.libfoo-AAAAAAA",
      "checksums": [
        {
          "algorithm": "SHA256",
          "checksumValue": "ハッシュ値"
        },
        {
          "algorithm": "SHA1",
          "checksumValue": "ハッシュ値"
        }
      ],
      "licenseConcluded": "NOASSERTION",
      "licenseInfoInFiles": [
        "NOASSERTION"
      ],
      "copyrightText": "NOASSERTION"
    }
  ],
  // 構成するパッケージ群
  "packages": [
    {
      "name": "Example.Package",
      "SPDXID": "SPDXRef-Package-Example.Package",
      "downloadLocation": "NOASSERTION",
      "filesAnalyzed": false,
      "licenseConcluded": "NOASSERTION",
      "licenseDeclared": "NOASSERTION",
      "copyrightText": "NOASSERTION",
      "versionInfo": "1.0.0",
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:foo/Example.Package@1.0.0"
        }
      ],
      "supplier": "NOASSERTION"
    },
  ],
  "externalDocumentRefs": [],
  "relationships": [],
  // SBOMドキュメント情報
  "spdxVersion": "SPDX-2.2",
  "dataLicense": "CC0-1.0",
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "example-project-1.0.0",
  "documentNamespace": "http://spdx.org/spdxdocs/example-project-1.0.0-12345678",
  // SBOM作成情報
  "creationInfo": {
    "created": "2026-01-16T12:00:00Z",
    "creators": [
      "Organization: ExampleOrg",
      "Tool: ExampleSBOMGenerator-1.0"
    ]
  },
  "documentDescribes": []
}

SLSAの例としてGitHub Actionsのアテステーションレポートを見てみましょう。ジョブでアテステーションレポートをid-token署名付きで生成、アテステーション一覧からたどることができます。ここには、いつ(日付)、だれが(実行者)、どこで(GitHubホステッド環境)、どのように(どの時点のどのワークフロー)そのソフトウェアを生成したかが記載されます。たしかにこれがあれば、そのソフトウェアがGitHub Actionsで生成されたことがわかります。

SLSAの例

SBOMで構成要素を把握し、SLSAでその生成プロセスを保証することで、ソフトウェアの信頼性を高めることができるという考え方とわかります。

OSSライブラリ開発者にとってのメリット

OSSライブラリの開発者としては、自身のOSSライブラリの構成を把握し、配布物が外部から検証可能な形で提供できることに一定のメリットがあります。

  1. SBOMがあれば、サプライチェイン攻撃に巻き込まれたとき、いつの時点のライブラリが影響を受けたのか特定しやすくなる
  2. SLSAがあれば、自身のライブラリが正当に生成されたものであることを示せる

サプライチェイン攻撃が発生したときにSBOMを確認することで、いつの時点のライブラリが影響を受けたのか特定しやすくなります。また、SBOM発行時の設定次第では、構成するライブラリにMPLやGPLライセンスなど自身が意図しないライセンスのライブラリが含まれているか確認できます。

SLSAはソフトウェアのサプライチェインのセキュリティレベルを評価するフレームワークで、レベル1(低)からレベル4(高)まであります。SLSAレベルが高いほどソフトウェアが改ざんされていないことを外部から検証しやすくなります。従来からSHA256などハッシュ値による検証はありましたが、そのハッシュ値が正当に生成されたものであるかまでは検証できません。それに対してSLSAは、例えばGitHub Actionsでビルドしてリリースしている場合に、ソフトウェアが生成されたから配布されるまでのプロセスとセットにすることで、ただのハッシュ値検証よりも強力にパッケージの正当性を保証します。

もう少し詳しくSBOMとSLSAについて見ていきましょう。

SBOM

SBOMは、そのソフトウェア(あるいはコンテナイメージ)に含まれる依存関係を、機械可読な形で列挙するものです。SBOMのフォーマットとしてSPDXとCycloneDXがよく使われますが、いずれにしてもSBOMには次の情報が記載されます。

  • 使われているライブラリとそのバージョン
  • 直接依存・間接依存の区別
  • ライセンス情報
  • ハッシュ

SBOMには、Source SBOMとBuild SBOMの2種類があります。Source SBOMは、ソースコードの依存関係から生成されるSBOMで、例えばロックファイル(packages.lock.json)から生成されます。一方、Build SBOMは、ビルド成果物から生成されるSBOMで、実際に含まれているライブラリを正確に反映します。

ただし、SBOMはSBOMファイルが正しいかは保証せず、改ざんされた場合もSBOM自体では判別できず、誰が作成したかの保証もできません。それって困りますよね? ということで、成果物がどうやって生成されたかを保証するSLSAが補完的に欲しくなります。

SBOMの生成ツール

OSSなら、microsoft/sbom-toolanchore/syftがよく利用されています。両方ともCLIツールを提供していますが、sbom-toolは.NET向けのMSBuildタスクがありNuGetパッケージ作成時に自動的に含めることができます。

また、GitHubもリポジトリ > Insights > Dependency graph > Generate SBOMからSBOMを生成できます。Export SBOMしたことはなくても、Dependency graphページをみたことがある人は多いんじゃないでしょうか。

SBOMをエキスポート

SBOMのフォーマット

SBOMのフォーマットとしてSPDXCycloneDXがよく使われます。SPDXはLinux Foundationが策定したフォーマットで、オープンソースソフトウェアのライセンス情報を管理するために設計されました。一方、CycloneDXはOWASPが策定したフォーマットで、セキュリティに重点を置いています。どちらも広く使われていますが、SPDXはライセンス情報に強みがあり、CycloneDXはセキュリティ情報に強みがあります。

CycloneDXは脆弱性情報やVEXなどセキュリティ用途の拡張が充実しており、開発中のソフトウェアのSBOM生成に向いています。一方、SPDXはライセンス情報に強みがあり、配布物に同梱するSBOMとして向いています。どっちかのフォーマットに統一するという感じではないようです。OSSライブラリ開発者としては、配布物にSBOMを同梱する場合はSPDXを選ぶとよいでしょう。

なお、SPDXにはバージョン2.2/2.33.0がありますが、現時点では2.2/2.3対応ツールが主流なため3.0に手を出すのはまだ早い印象です。3.0はデータモデルが刷新されており、2.xの文書を3.0として扱うには変換が必要です。

SBOMの課題

使っていて感じた課題がいくつかあります。

  • ツールによって生成結果が異なる
  • 生成時に指定しなかった情報はNOASSERTIONになる
  • SPDX v3.0はいつから使えるのか

SBOMはフォーマットがあるにも関わらず、ツールによって生成結果が微妙に異なっており一貫したフォーマット提供に課題があります。ツールによって含まれるコンポーネント数が違うのはしょうがないとしても、依存階層が一致しないとか困ったものです。

また、SBOM生成時にライセンスなどを指定しない限りはNOASSERTIONになります。このため、必要な情報を含めるにはどうすればいいのかはツールごとに調べる必要があります。ライセンス情報はSigstoreなどから収集することが多いようなので、ライブラリやツールのバージョン途中からライセンス変わったらどうなるの?新しいバージョンでライセンス変わった時に反映はいつ?など気になる点があります。

SPDX v3.0は2024年にリリースされましたが、一年たっても試験対応ポジションのツールが多いのも気になります。SBOMツールには外部からのコントリビュートは明示的に拒否するポリシーをもつケースもあることから、セキュリティに関わるので時間がかかっても安定したフォーマット出力を優先する気配は感じます。

SLSA

SLSAは、その成果物が信頼できる工程で作られたことを段階的に保障することを目的としたフレームワークです。SLSA v1.0には4つのレベルがあり、レベルが上がるほど信頼性が高くなります。

  • Level 1: ビルド来歴(Provenance)がある
  • Level 2: ビルド来歴(Provenance)が署名されており、ホステッドビルド環境で生成されている
  • Level 3: 改ざんに強いビルド基盤
  • Level 4: 再現可能・検証可能

GitHub Actionsを使うことで、ビルド来歴提供やビルド自動化が実現できるのでSLSAレベル1と2は比較的簡単に達成できます。Provenanceを具体化したものが、先のアテステーションレポートでありレベル2の提供です。レベル3にはビルド基盤が改ざんしにくくなる工夫を要します。GitHubでのSLSA対応はドキュメントが用意されているので参考になります。

ただ、アテステーションレポートがあるかといってソフトウェアが安全である保障はないことには注意が必要です。SLSAでソフトウェアのソースコードとビルド手順へのリンクは提供されますが、ソフトウェア自体のリスク判断は行いません。例えば、悪意のあるコードがソースコードに含まれている場合、そのソースコードをビルドした成果物はSLSAレベル4であっても安全とは言えません。

SLSAの検証ツール

SLSAアテステーションレポートを検証するツールとして、ghコマンドやslsa-verifierがあります。GitHub Actionsを使っているなら、ghコマンドを用いるのが簡単です。

# Verify an artifact linked with a repository
$ gh attestation verify example.bin --repo github/example

# Verify an OCI image using attestations stored on disk
$ gh attestation verify oci://<image-uri> --owner github --bundle sha256:foo.jsonl

# Verify an artifact signed with a reusable workflow
$ gh attestation verify example.bin --owner github --signer-repo actions/example

slsa-verifierでもGitHubアテステーションレポートは検証できます。

$ curl -sSO https://bcr.bazel.build/modules/aspect_rules_lint/1.3.4/MODULE.bazel
$ curl -sSO https://bcr.bazel.build/modules/aspect_rules_lint/1.3.4/MODULE.bazel.intoto.jsonl
$ slsa-verifier verify-github-attestation --source-uri github.com/aspect-build/rules_lint --builder-id https://github.com/bazel-contrib/publish-to-bcr/.github/workflows/publish.yaml --attestation-path MODULE.bazel.intoto.jsonl MODULE.bazel

SLSAレベル3は現実的なのか

SLSAレベル3の改ざんに強いビルド基盤とは、誰も変更できないことを意味するのではなく、変更された場合にその事実を後から検証できる、という意味である点には注意が必要です。言い換えると、「そのビルド成果物が事前に定義されたビルド手順から人の手による介入なく生成されたものであることを後から検証できる」ことを意味します。

SLSAレベル2以上はホスト環境を求めていますが、これはビルド環境の完全性をGitHubなどクラウド事業者の責任範囲において、代わりにユーザーが直接触れないことを信頼の根拠にしていると捉えられます。GitHub ActionsのホステッドランナーはMicrosoftが管理しており、ユーザーはその環境に直接アクセスできません。つまり、ビルド環境の完全性をMicrosoftに依存することで、ユーザーはビルド環境が改ざんされていないことを信頼できます。

誤解していたこと

改ざんに強いは「ビルドステップ自体を改ざんできない」という意味ではないことは注意が必要です。私はこれを当初誤解していました。GitHub ActionsでSLSA3を達成するためのドキュメントが提供されています。これはビルドにReusable Workflowを使うことを求めていますが、ビルドステップを別リポジトリで管理するのが直接的にビルド自体が改ざんできなくなるかというと、個人的には疑問があります。そのリポジトリがプライベートリポジトリである場合、「ビルドステップは外部から改ざんするのは非常に困難である」と言えそうですが、パブリックリポジトリである場合、誰でもプルリクエストを送れるためマージ・レビュー次第では「ビルドステップが改ざんに強いであるとは言い切れない」です。改ざんに強いはあくまでも外部からのPRを受け付けない、内部の変更に対しても注意を払うしかないということになります。

実際にできそうなライン

GitHubドキュメントを見る限り、OSSライブラリ開発者としてSLSAレベル3を達成する以下の条件を満たすのは現実的なラインと考えています。

  • ビルドステップをReusable Workflowで管理する
  • アテステーションレポートを生成する

GitHub Actionsでアテステーションを生成するのは、ジョブに権限をつけて1つステップを追加するだけで済みます。これを丸っとReusable Workflowにしておくイメージです。

on:
  # Reusable Workflowとして呼び出される
  workflow_call:

jobs:
  build:
    # 権限が必要
    permissions:
      attestations: write
      contents: read
      id-token: write
    runs-on: ubuntu-24.04
    steps:
      # ...ビルドステップで成果物を生成する
      - name: Build lib...
        run: ...

      # アテステーションを生成すると、自動的にGitHubに登録される
      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
        with:
          subject-path: "./publish/libfoo.so"

しかし、Reusable Workflowに分けても、それを改ざんされたら意味がないのでなんだかちぐはぐ感は否めない気もします。

改ざんとイミュータブルリリース

改ざんについて考えると、同一リリースやタグを上書き可能な場合、後から改ざんしてアテステーションを差し替える余地があるように思えます。例えば、GitHub Releasesで同一タグを上書きできる設定になっている場合、ビルド工程を丸っと乗っ取られた場合に、同じタグやリリースを後から上書きされる可能性があります。権限次第でアテステーションも削除ができますしね。これを仕組みで防ぐには、イミュータブルリリースが必要になります。

幸いGitHubはImmutable Releasesをサポートしているので、これを有効にすることで同一タグの上書きを防ぐことができます。SLSAうんぬんに関わらず、この設定は有効にしておくのがよさそうです。

SBOMとSLSAの活用例

SBOMとSLSAを使った活用例はいくつか見かけるので紹介します。

SBOMをライセンスリスク管理に使う

SBOMでライセンス情報も出力することで、ライセンスリスク管理が可能になります。例えば、MITで配布したいライブラリに、GPLライセンスや商用ライセンスが混入することは避けたいでしょう。しかし、ライブラリの依存関係が複雑になると、どのライブラリがどのライセンスなのか把握するのは困難です。SBOMを使うことで、ライセンス情報を自動的に収集し、ライセンスリスクを管理できます。

SLSAをアーティファクトのハッシュ値検証に使う

SLSAアテステーションが発行されていると、単にハッシュ値を配布するだけでなく「ハッシュが何のか + そのハッシュがどの工程で生成されたか」まで含めて検証できるようになります。よくリリースアーティファクトにlibfoo.solibfoo.so.sha256のように提供されているハッシュ値は、ダウンロード途中で改ざんされた際に改ざんを検出できるものの、そのハッシュ値自体が改ざんされていた場合には検出できません。つまり、攻撃者がlibfoo.solibfoo.so.sha256の両方を改ざんした場合、利用者がそれを検出できる仕組みではありません。

SLSAアテステーションレポートには、対象アーティファクトのハッシュ値が含まれ、利用者はそのビルドステップで生成されたものであることを検証できます。

例えば、aquaがこれを使ってインストールするパッケージの改ざんチェックを行っています。

まとめ

個人的に調べたSBOMとSLSAの状況についてのメモ書きでした。

OSSライブラリ開発者としては、SBOMを配布物に同梱することは悪くなさそうです。ただGitHubのInsightsから見てもらったり、Dependabotに任せることができている現状からすると、SBOMを積極的に活用する場面はまだ少ない印象です。標準ビルドで自動的に生成されるなら、配布パッケージに入れても損はないかなという温度感。

SLSAに関しては、GitHub Actionsを使っている場合はアテステーションレポートを生成するのは簡単なので、OSSライブラリの信頼性を高めるために導入自体は悪くなさそうです。GitHub Actionsを利用しているOSSライブラリ開発者としては、SLSAレベル3 + アテステーションレポートを添えるのは難しくない感触です。

SBOM/SLSAに関わらず、リリースが後から上書きされるのは利用者からすると疑いしかないので、SBOM/SLSAに関わらずGitHubのイミュータブルリリースは有効にしておくのがよさそうです。

参考

GitHub

規格

ドキュメント

ブログ

NuGetのロックファイルは使うべきなのか

NuGetにはロックファイル(packages.lock.json)を用いてリストアする機能があります。npmではpackage-lock.jsonが当たり前に使われていますが、C#のプロジェクトでロックファイルを使っている例はあまり見かけません。

最近SBOMについて調べる中で、なぜNuGetのロックファイルがあまり使われていないのか、そもそも使うべきなのかを考えてみました。この記事では、NuGetのロックファイルの仕組みと、C#におけるパッケージ管理の文化的な背景から、ロックファイルの必要性について考察します。

ロックファイルとは

ロックファイルとは、プロジェクトが依存するパッケージのバージョンを固定化するためのファイルです。

Microsoft Learnを見ると、プロジェクトが依存するパッケージには、「直接依存するもの(トップレベル・直接/Top-level or Direct)」と「間接的に依存するもの(トランジティブ・推移的/Transitive)」があります。 イメージしやすいようにnpmで例えると、@modelcontextprotocol/sdkパッケージを入れるとします。 この場合、@modelcontextprotocol/sdkが直接依存するパッケージで、@modelcontextprotocol/sdkが依存している@hono/node-serverajvなどは間接的に依存するパッケージです。

npmで@modelcontextprotocol/sdkの間接的に依存するパッケージが確認できる

ロックファイルは、あるパッケージをインストールしたときのバージョンと、そのパッケージを導入したときに推移的にインストールされたパッケージのバージョンを記録します。これにより、同じプロジェクトを別の環境でセットアップしたときに、同じバージョンのパッケージがインストールされることを保証します。

NuGetのロックファイル

NuGetにもロックファイルを利用する機能がありますが、デフォルトでは無効になっています。ロックファイルを利用するには.csproj<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>に設定して、プロジェクトをリストア(dotnet restore)します。すると、.csprojがあるパスにpackages.lock.jsonというファイルが生成されます。

  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>

試してみましょう。プロジェクト追加 → 初回のパッケージ追加 → リストア → ロックファイル追加後のリストアを順に実行します。今回は私の書いているライブラリであるSkiaSharp.QrCodeパッケージを使用します。NuGetを見るとSkiaSharpSkiaSharp.NativeAssets.macOS/SkiaSharp.NativeAssets.Win32に依存していることがわかります。

NuGetで確認できるSkiaSharp.QrCodeパッケージの依存関係。SkiaSharpやNativeAssetsパッケージに依存していることがわかる

まずはコンソールプロジェクトを作成し、SkiaSharp.QrCodeパッケージを追加してリストアします。この時点ではロックファイルは生成されていません。

$ mkdir -p ConsoleApp3 && cd ConsoleApp3
$ dotnet new console -n ConsoleApp3
$ dotnet package add SkiaSharp.QrCode
$ dotnet restore
Restore complete (0.9s)

Build succeeded in 1.1s

$ ls -la
ls -la
total 16
drwxrwxr-x    3 guitarrapc   guitarrapc      0 Jan 14 16:58 .
drwxrwxr-x    8 guitarrapc   guitarrapc   4096 Jan 14 16:58 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    356 Jan 14 16:59 ConsoleApp3.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc    105 Jan 14 16:58 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:00 obj

続けて、<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>を追加して再度リストアします。すると、packages.lock.jsonファイルが生成されます。

$ cat <<EOF > ConsoleApp3.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SkiaSharp.QrCode" Version="0.12.0" />
  </ItemGroup>

</Project>
EOF

$ dotnet restore
Restore complete (0.6s)

Build succeeded in 1.1s

$ ls -la
ls -la
total 24
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:03 .
drwxrwxr-x    8 guitarrapc   guitarrapc   4096 Jan 14 16:58 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    427 Jan 14 17:03 ConsoleApp3.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc    105 Jan 14 16:58 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:03 obj
-rw-rw-r--    1 guitarrapc   guitarrapc   1314 Jan 14 17:03 packages.lock.json  # <- 追加!

ロックファイルの中身を見ると、プロジェクトで直接参照しているパッケージと、間接的に参照しているパッケージが区別されつつ、各パッケージのバージョンが記録されています。

  • プロジェクトで直接参照させたパッケージSkiaSharp.QrCodeには"type": "Direct"が指定され、最新バージョンが利用
  • SkiaSharp.QrCodeライブラリが依存しているSkiaSharpSkiaSharp.NativeAssets.Win32などのパッケージには"type": "Transitive"が指定
$ cat packages.lock.json
{
  "version": 1,
  "dependencies": {
    "net10.0": {
      "SkiaSharp.QrCode": {
        "type": "Direct",
        "requested": "[0.12.0, )",
        "resolved": "0.12.0",
        "contentHash": "DTSyBl/rJXcGbSuIzkv20pkTTPUaZbFmouWrOtHG0a2Ide0IsbU9o1mUJb1HsiOgUEK6aAX2+MzP0n7GPssiSA==",
        "dependencies": {
          "SkiaSharp": "3.119.1",
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "+Ru1BTSZQne3Vp+vbSb50Ke3Nlc3ZnItxx4+751J9WZ8YzLKAV/n+9DAo4zFTyeCI//ueT63c+VybmTTpYBEiw==",
        "dependencies": {
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp.NativeAssets.macOS": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "6hR3BdLhApjDxR1bFrJ7/lMydPfI01s3K+3WjIXFUlfC0MFCFCwRzv+JtzIkW9bDXs7XUVQS+6EVf0uzCasnGQ=="
      },
      "SkiaSharp.NativeAssets.Win32": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "8C4GSXVJqSr0y3Tyyv5jz6MJSTVUyYkMjeKrzK+VyZPGLo89MNoUEclVuYahzOCDdtbfXrd2HtxXfDuvoSXrUw=="
      }
    }
  }
}

ロックファイルを使ったリストア

ロックファイルを使用している場合、dotnet restoreコマンドはpackages.lock.jsonファイルを参照して、NuGetの依存を再評価しつつ指定されたバージョンのパッケージをインストールします。この時パッケージが取得できなかったなど必要があれば、ロックファイルのバージョンは更新されます。不変じゃないのはnpmのpackage-lock.jsonと同じです。

npm ciのように、ロックファイルに記録されたバージョンを厳密に再現する場合、dotnet restore --locked-modeコマンドを使うか、<RestoreLockedMode>true</RestoreLockedMode>を設定します。npm同様、CIではこのオプションを有効にするのがいいでしょう。

ローカルでは通常のdotnet restoreを実行し、CI(GitHub Actionsを想定)ではロックファイルに厳密に従うようにするなら次のように設定します。これにより、異なる環境であっても同じバージョンのパッケージが保証されます。

  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
    <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
  </PropertyGroup>

ロックファイルとCentral Package Managementの組み合わせ

Central Package Management(以降CPM)は、複数プロジェクトのパッケージバージョンをDirectory.Packages.propsで一元管理する機能です。ロックファイルとCPMを組み合わせた場合の動作を確認してみましょう。

ロックファイルはCPMが有効でも特別な対応はしません。つまり、Directory.Packages.propsでバージョンを一元管理していても、ロックファイルは個々の.csprojパスに生成されます。実際に試してみます。

ConsoleApp4とConsoleApp5の2つのプロジェクトを持つソリューションを作成し、Directory.Packages.propsSkiaSharp.QrCodeのバージョンを一元管理します。ロックファイルpackages.lock.json、Directory.Packages.propsのパスではなく各プロジェクトに生成されることを確認します。

まずはルートにDirectory.Build.propsDirectory.Packages.propsを作成し、ロックファイルとCentral Package Managementを有効にします。

$ cat <<EOF > Directory.Build.props
<Project>
  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
    <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
  </PropertyGroup>
</Project>
EOF

$ cat <<EOF > Directory.Packages.props
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="SkiaSharp.QrCode" Version="0.12.0" />
  </ItemGroup>
</Project>
EOF

続いて、2つのコンソールプロジェクトを作成し、SkiaSharp.QrCodeパッケージを追加してリストアします。ロックファイルはDirectory.Packages.propsではなく各プロジェクトに生成されます。1

$ mkdir -p src/ConsoleApp4 && cd src/ConsoleApp4
$ dotnet new console
$ dotnet package add SkiaSharp.QrCode

$ cd ../../
$ mkdir -p src/ConsoleApp5 && cd src/ConsoleApp5
$ dotnet new console
$ dotnet package add SkiaSharp.QrCode

$ cd ../../
$ dotnet new sln -f slnx
$ dotnet sln add src/ConsoleApp4/ConsoleApp4.csproj
$ dotnet sln add src/ConsoleApp5/ConsoleApp5.csproj
$ dotnet restore
Restore complete (1.4s)

Build succeeded in 1.7s

$ ls -laR
.:
total 20
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:41 .
drwxrwxr-x    8 guitarrapc   guitarrapc   4096 Jan 14 16:58 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    210 Jan 14 17:38 Directory.Build.props
-rw-rw-r--    1 guitarrapc   guitarrapc    327 Jan 14 17:39 Directory.Packages.props
-rw-rw-r--    1 guitarrapc   guitarrapc    181 Jan 14 17:43 lockfile.slnx
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 src

./src:
total 12
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 .
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:41 ..
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 ConsoleApp4
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 ConsoleApp5

./src/ConsoleApp4:
total 20
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 .
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    324 Jan 14 17:36 ConsoleApp4.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc         105 Jan 14 17:35 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:43 obj
-rw-rw-r--    1 guitarrapc   guitarrapc     66 Jan 14 17:43 packages.lock.json

./src/ConsoleApp5:
total 20
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 .
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    324 Jan 14 17:36 ConsoleApp5.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc    105 Jan 14 17:36 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:43 obj
-rw-rw-r--    1 guitarrapc   guitarrapc     66 Jan 14 17:43 packages.lock.json

CPMなので.csprojファイルの中身を見てもパッケージのバージョン指定はありません。

$ cat ./src/ConsoleApp4/ConsoleApp4.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SkiaSharp.QrCode" />
  </ItemGroup>

</Project>

$ cat ./src/ConsoleApp4/packages.lock.json
{
  "version": 2,
  "dependencies": {
    "net10.0": {
      "SkiaSharp.QrCode": {
        "type": "Direct",
        "requested": "[0.12.0, )",
        "resolved": "0.12.0",
        "contentHash": "DTSyBl/rJXcGbSuIzkv20pkTTPUaZbFmouWrOtHG0a2Ide0IsbU9o1mUJb1HsiOgUEK6aAX2+MzP0n7GPssiSA==",
        "dependencies": {
          "SkiaSharp": "3.119.1",
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "+Ru1BTSZQne3Vp+vbSb50Ke3Nlc3ZnItxx4+751J9WZ8YzLKAV/n+9DAo4zFTyeCI//ueT63c+VybmTTpYBEiw==",
        "dependencies": {
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp.NativeAssets.macOS": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "6hR3BdLhApjDxR1bFrJ7/lMydPfI01s3K+3WjIXFUlfC0MFCFCwRzv+JtzIkW9bDXs7XUVQS+6EVf0uzCasnGQ=="
      },
      "SkiaSharp.NativeAssets.Win32": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "8C4GSXVJqSr0y3Tyyv5jz6MJSTVUyYkMjeKrzK+VyZPGLo89MNoUEclVuYahzOCDdtbfXrd2HtxXfDuvoSXrUw=="
      }
    }
  }
}

プロジェクトごとに異なるパッケージを参照することもある2ので挙動としては理解できますが、packages.lock.jsonの役割的にはDirectory.Packages.propsのパスに1つだけ生成される方が自然な気はします。ただ、.csprojでパッケージをオーバーライドする場合もあるので、今の設計のままになりそうです。ソリューションレベルやリポジトリレベルのロックファイルについてIssueも立っていますが、現時点では対応の予定はないようです。

ロックファイルを使っている例

個人的にはロックファイルは使いませんが、ロックファイルが使われる例もあります。例えば、GitHub ActionsでNuGetのパッケージキャッシュを利用するactions/cacheがロックファイルを使ったサンプルを提示しています。サンプルは、ロックファイルをキャッシュキーに含めることで、パッケージの変更があった場合のみキャッシュを更新させます。

- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

ただ、先にあげたようにCentral Package Managementを使っている場合、プロジェクトごとにロックファイルができます。だったら、Directory.Packages.propsでバージョンが1.1.1のように指定されているはずなので、Directory.Packages.props自体をキャッシュキーに含めたほうがより明示的に更新タイミングが分かるのとキャッシュ効率もほぼ変わらないと予測できます。

- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('Directory.Packages.props') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

実際にロックファイルをキャッシュキーにしている記事を見ても、キャッシュによる効果はあまり感じられなかったと書かれています。プロジェクトで利用しているパッケージのボリュームによりますが、GitHub Actionsのキャッシュリストアは早くないので、キャッシュヒット率が上がっても劇的に早くならないのは納得できます。このため私は、GitHub ActionsでNuGetのキャッシュは使っていません。

C#でロックファイルは必要か

本題です。C#でロックファイルは必要なのでしょうか? 個人的にはロックファイルはあまり必要ないと考えています。それは、C#はNuGetのパッケージをバージョン直指定する文化があり、推移的パッケージの解決も「競合した場合最も低いバージョンを選ぶルール」があるため、決定論的にパッケージバージョンが決定されるからです。

実際、GitHubでRestorePackagesWithLockFileをキーに検索すると7300件程度と、C#リポジトリ全体が6.9M件あることからすると少ないです。このことから、C#のプロジェクトでロックファイルを使う文化があまり根付いていないことがわかります。

npmとNuGetの文化の違い

ロックファイルが特に有効なのは、パッケージの依存関係がレンジ指定されている場合です。npmでは、^1.2.3~1.2.3のようにレンジ指定することが一般的です。このため、ロックファイルを使わないと、同じリポジトリをクローンしても、リストアタイミングで異なるパッケージバージョンがインストールされる可能性を持っています。ロックファイルを使うことで、同じバージョンのパッケージを確実にインストールできます。

一方、NuGetの文化としてレンジ指定することがなく、バージョンが直接指定されます。また、推移的な依存パッケージで競合があった場合、最も低いバージョンを選ぶよう解決されるルールです。このためロックファイルがなくとも、.csprojやDirectory.Packages.propsで直接バージョンが指定されている限りは決定論的(deterministic)にバージョンが決定されます。

C#でバージョン直指定なのはNuGetのUI/UXがそうであることに起因してそうです。NuGetにおいては、レンジ指定を維持するよりバージョン指定することを促す体験で一貫しています。

例えば、dotnet package addでパッケージを追加してもバージョンは指定されます。

# バージョン指定を省略した場合、自動的に最新バージョンが指定される
$ dotnet package add SkiaSharp.QrCode

# バージョンを指定することも可能だが、最新バージョンを指定するなら不要
$ dotnet package add SkiaSharp.QrCode --version 0.12.0

Visual StudioやRiderのNuGet Package Managerでパッケージをインストール・アップグレードする際もバージョンを指定するようになっており、レンジ指定をサポートしていません。

Visual StudioのManage NuGet Packageでもバージョンを指定する

npmのようにバージョンをレンジ/ワイルドカード指定をするには.csprojを直接手で編集する必要があり、ほとんどの人は使いません。

直接.csprojの編集が必要

レンジ指定していても、Dependabotで自動更新させるとバージョンは直指定されます。

SBOMの視点から

SBOMの視点から見ると、ロックファイルpackages.lock.jsonはSource SBOMであって補助的な役割に過ぎません。SBOMにおいて最も重要なのはBuild SBOMであり、C#でもビルド時にobj/project.assets.jsonへ出力します。

$ dotnet build -c Release
$ ls -l ./src/ConsoleApp4/obj
total 64
-rw-rw-r--    1 guitarrapc   guitarrapc  22356 Jan 14 17:46 ConsoleApp4.csproj.nuget.dgspec.json
-rw-rw-r--    1 guitarrapc   guitarrapc   1304 Jan 14 17:43 ConsoleApp4.csproj.nuget.g.props
-rw-rw-r--    1 guitarrapc   guitarrapc    150 Jan 14 17:43 ConsoleApp4.csproj.nuget.g.targets
drwxrwxr-x    3 guitarrapc   guitarrapc      0 Jan 14 18:14 Debug
-rw-rw-r--    1 guitarrapc   guitarrapc  28542 Jan 14 17:46 project.assets.json
-rw-rw-r--    1 guitarrapc   guitarrapc    684 Jan 14 17:46 project.nuget.cache

project.assets.jsonファイルには、ビルドに使用されるすべてのパッケージとそのバージョンが含まれています。これにより、SBOMを生成する際により正確な依存関係情報を取得できます3。実際、SBOMツールの[sbom-tool]やsynkCycloneDXはNuGetに対してはproject.assets.jsonを参照しています。

まとめ

C#においてロックファイルはデフォルトで無効になっており、実際に使われている例もあまり見かけません。個人的には、パッケージをバージョン直指定する文化と、決定論的なバージョン解決の仕組みにより、ロックファイルを使うメリットは小さいと考えています。今後ソフトウェアサプライチェインのセキュリティがより重要になる中で、ロックファイルの役割も見直される可能性はあります。しかし現時点では、C#のエコシステムにおいて決定論的な保証ができないケースを思いつきません。

ただし、以下のようなケースでは検討の余地があります。

  • 推移的な依存関係がどう変わったかを細かく追いかけたい場合
  • CIでのキャッシュ戦略として活用する場合(ただし、Central Package Management使用時はDirectory.Packages.propsで十分)

参考

ドキュメント

ブログ

GitHub


  1. .NET SDK 10.0.102以降でdotnet new sln -f slnxが利用可能です
  2. CPMを使っていてプロジェクトでバージョンをオーバーライドすると、気づくことが難しいこともあり私は極力避けたほうがいいと考えています
  3. 他のファイルも組み合わせますが、ビルド時に入るファイル一覧として重要

AWS Lambdaの.NET10対応とC#のファイルベースプログラムのサポート

2025年11月に.NET 10がリリースされましたが、2026年1月6日にAWS Lambdaが.NET 10をサポートしました。 これに伴いファイルベースプログラムのC# Lambda関数もサポートされ、.csファイルだけ用意すればLambda関数をデプロイできるようになりました。

AWS LambdaでC#を書く体験が変わったので、今回はその魅力を紹介します。

ファイルベースプログラムのC#については、以前書いた記事も参考にしてください。

はじめに

ファイルベースプログラムのC# Lambda関数は、.csファイルだけでLambda関数をデプロイできます。

これまでは.csproj + .csファイルを用意 → nugetパッケージを追加 → ビルド → zip化 → デプロイ...といった手順が必要でしたが、簡単になりました。 IaCでは引き続きビルドが必要ですが、dotnet toolでデプロイする分にはビルドステップも不要で、体験的にはPythonやNode.jsのようにスクリプトファイルをそのままデプロイするのに近い感覚です。

  • dotnet toolでデプロイ: .csファイルだけ用意したらデプロイコマンド実行
  • IaCでデプロイ: .csファイルをコンパイル、バイナリを指定してデプロイ

次のコードはコメントを抜いたミニマムなサンプルC#コードで、入力された文字列を大文字に変換して返すだけの関数です。

#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4
#:property TargetFramework=net10.0

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// The function handler
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
  .Build()
  .RunAsync();

[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

これをToUpper.csという名前で保存し、dotnet lambda deploy-functionコマンドでデプロイするだけで、Lambda関数が作成されます。

File-based C# Lambda関数をデプロイする

もう少し詳しく見ていきましょう。

C#コードはAmazon.Lambda.Templatesで追加できるlambda.FileBasedテンプレートをベースに開始できますし、そんなのを入れなくてもコピー&ペーストでも大丈夫1です。

テンプレートを使う場合は、次のコマンドでプロジェクトを作成します。

dotnet new lambda.FileBased -n ToUpper

あるいは、空のディレクトリを作成して、.csファイルを作成します。今回はToUpper.csという名前にします。

$ tree
.
└── ToUpper.cs

C#コードを用意する

テンプレートから生成されるToUpper.csファイルの中身は先のコードとほぼ同じです。 コード全体を改めて示します。

// C# file-based Lambda functions can be deployed to Lambda using the
// .NET Tool Amazon.Lambda.Tools version 6.0.0 or later.
//
// Command to install Amazon.Lambda.Tools
//   dotnet tool install -g Amazon.Lambda.Tools
//
// Command to deploy function
//    dotnet lambda deploy-function <lambda-function-name> MyLambdaFunction.cs
//
// Command to package function
//    dotnet lambda package MyLambdaFunction.zip MyLambdaFunction.cs


#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4

// Explicitly setting TargetFramework here is done to avoid
// having to specify it when packaging the function with Amazon.Lambda.Tools
#:property TargetFramework=net10.0

// By default File-based C# apps publish as Native AOT. When packaging Lambda function
// unless the host machine is Amazon Linux a container build will be required.
// Amazon.Lambda.Tools will automatically initate a container build if docker is installed.
// Native AOT also requires the code and dependencies be Native AOT compatible.
//
// To disable Native AOT uncomment the following line to add the .NET build directive
// that disables Native AOT.
//#:property PublishAot=false

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// The function handler that will be called for each Lambda event
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

// Build the Lambda runtime client passing in the handler to call for each
// event and the JSON serializer to use for translating Lambda JSON documents
// to .NET types.
await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
  .Build()
  .RunAsync();

// Since Native AOT is used by default with C# file-based Lambda functions the source generator
// based Lambda serializer is used. Ensure the input type and return type used by the function
// handler are registered on the JsonSerializerContext using the JsonSerializable attribute.
[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

コードはC#のトップレベルステートメントを使ったExecutable assembly handlersスタイル名になっているので、Mainメソッドは不要で代わりにawait LambdaBootstrapBuilder.Create()が必要です。

public async Task<string> HandleRequest(T input, ILambdaContext context)がハンドラーのシグネチャです。第一引数が受け取るイベントデータの型で、第二引数がILambdaContextです。上記コードは、文字列を受け取り大文字に変換して返すので、(string input, ILambdaContext context) => { ... }となっています。 要するに、ハンドラーは第一引数に受け取る型、第二引数にILambdaContextを指定すれば任意の非同期メソッドにできると考えればいいでしょう。

整理すると、開発者が書く部分は2か所です。

1. ハンドラーに処理の実装を書く

Lambdaで処理したい内容はこのハンドラー内に書きます。

var handler = (string input, ILambdaContext context) =>
{
    // ここに処理を書く
};

2. Lambdaで受け取る入力をシリアライズコンテキストに登録する

リフレクションなしでLambdaへの入力をC#オブジェクトに変換するため、System.Text.Json.Serializationのシリアライズコンテキストに型を登録します。これでソースジェネレーターがシリアライズ/デシリアライズコードを生成します。

今回は入力が文字列想定なのでJsonSerializable(typeof(string))をシリアライズコンテキストに登録、ハンドラーに来た文字列をToUpper()で大文字に変換してレスポンスを返します。

[JsonSerializable(typeof(string))] // ここで受け取る型のシリアライズを登録する
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

デプロイする

Lambdaに直接.csファイルをデプロイするdotnet tool Amazon.Lambda.Toolsがあります。 これを使うとCLIでデプロイできるのでインストールします。

$ dotnet tool install -g Amazon.Lambda.Tools
You can invoke the tool using the following command: dotnet-lambda
Tool 'amazon.lambda.tools' (version '6.0.3') was successfully installed.

AWS認証を取得しておきます。

$ aws sso login --profile your-profile

Lambda関数をデプロイします。

ここが従来に比べて大きく変わった点で、.csprojファイルを用意せずに.csファイルだけでデプロイできるようになっています。 これならGitHub ActionsなどのCI/CD環境でデプロイするのも簡単です。

$ dotnet lambda deploy-function ToUpper ToUpper.cs --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10 --profile your-profile
Amazon Lambda Tools for .NET Core applications (6.0.3)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Architecture not provided, defaulting to x86_64 for container build image.
Executing publish command
Starting container for native AOT build using build image: mcr.microsoft.com/dotnet/sdk:10.0-aot.
... invoking 'docker run --name tempLambdaBuildContainer-3fb48080-a444-499e-b6a4-16413be8c5d0 --rm --volume "C:\github\Lambda\DotnetFileAppFunction":/tmp/source/ -i mcr.microsoft.com/dotnet/sdk:10.0-aot dotnet publish "/tmp/source/ToUpper.cs" --output "/tmp/source\artifacts\ToUpper" --configuration "Release" --framework "net10.0" /p:GenerateRuntimeConfigurationFiles=true --runtime linux-x64 --self-contained True  /p:StripSymbols=true' from directory C:\github\Lambda\DotnetFileAppFunction
... docker run: Unable to find image 'mcr.microsoft.com/dotnet/sdk:10.0-aot' locally
... docker run: 10.0-aot: Pulling from dotnet/sdk
... docker run: 59287b3c3c70: Pulling fs layer
... docker run: 06762f394a85: Pulling fs layer
... docker run: e26f93cf9c70: Pulling fs layer
... docker run: 69c84e01b5c0: Pulling fs layer
... docker run: 47849234c411: Pulling fs layer
... docker run: 505db3b3094b: Pulling fs layer
... docker run: 95c4e06fe864: Pulling fs layer
... docker run: a3629ac5b9f4: Pulling fs layer
... docker run: 60fc5ac8adb0: Pulling fs layer
... docker run: da1a80ccb2fc: Pulling fs layer
... docker run: 46f592c23ae7: Pulling fs layer
... docker run: e26f93cf9c70: Download complete
... docker run: 95c4e06fe864: Download complete
... docker run: da1a80ccb2fc: Download complete
... docker run: 59287b3c3c70: Download complete
... docker run: 47849234c411: Download complete
... docker run: a3629ac5b9f4: Download complete
... docker run: 06762f394a85: Download complete
... docker run: 60fc5ac8adb0: Download complete
... docker run: 46f592c23ae7: Download complete
... docker run: a3629ac5b9f4: Pull complete
... docker run: 47849234c411: Pull complete
... docker run: 95c4e06fe864: Pull complete
... docker run: 46f592c23ae7: Pull complete
... docker run: e26f93cf9c70: Pull complete
... docker run: 59287b3c3c70: Pull complete
... docker run: 60fc5ac8adb0: Pull complete
... docker run: 505db3b3094b: Download complete
... docker run: 69c84e01b5c0: Download complete
... docker run: 69c84e01b5c0: Pull complete
... docker run: da1a80ccb2fc: Pull complete
... docker run: 06762f394a85: Pull complete
... docker run: 505db3b3094b: Pull complete
... docker run: Digest: sha256:d68a5e260330b659f7eae596a255bddbdc4e406e3579eb2d85d718ac58dd7dcb
... docker run: Status: Downloaded newer image for mcr.microsoft.com/dotnet/sdk:10.0-aot
... docker run:   Determining projects to restore...
... docker run:   Restored /tmp/source/ToUpper.csproj (in 13.86 sec).
... docker run:   ToUpper -> /root/.local/share/dotnet/runfile/ToUpper-6855d7fa7559aae751b6be03e6497d359004d36f9f6dae455063950209971e3d/bin/release_linux-x64/ToUpper.dll
... docker run:   Generating native code
... docker run:   ToUpper -> /tmp/source/artifacts/ToUpper/
Zipping publish folder C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper to C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper.zip
... zipping: ToUpper
Created publish archive (C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper.zip).
Creating new Lambda function ToUpper
New Lambda function created

arm64で動作させることもできますが、NativeAOTでのarm64ビルドはarm64マシンが必要です。

# x86_64マシンでarm64デプロイしようとするとエラーになる
$ dotnet lambda deploy-function ToUpper-arm64 ToUpper.cs --function-architecture arm64 --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10
Amazon Lambda Tools for .NET Core applications (6.0.3)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet
Host machine architecture (X64) differs from Lambda architecture (Arm64). Building Native AOT Lambda functions require the host and lambda architectures to match.

また、引数を最後に持っていくとFunction名がarm64になるので注意です2

# X: なぜかx86_64でビルドされる上、Function名がarm64になる
$ dotnet lambda deploy-function ToUpper-arm64 ToUpper.cs --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10 --function-architecture arm64

ビルド~デプロイ処理を見てみると、mcr.microsoft.com/dotnet/sdk:10.0-aotコンテナを使ってNative AOTビルドしています。 また、ビルドしたバイナリToUpperをzip化してLambda関数を作成しています。デプロイ後のファイルツリーを見てみると、artifactsディレクトリにアップロードしたToUpper.zipがあり、ToUpperディレクトリにNative AOTでビルドされたバイナリが入っています。

.
│  ToUpper.cs
│
└─artifacts
    │  ToUpper.zip
    │
    └─ToUpper
            ToUpper
            ToUpper.dbg

ToUpper.zipの中身はToUpperバイナリだけが入っています。

$ unzip -l artifacts/ToUpper.zip
Archive:  artifacts/ToUpper.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
  6925632  1980-00-00 00:00   ToUpper
---------                     -------
  6925632                     1 file

このことから、ファイルベースのC# Lambda関数は従来通りzip化されたバイナリをアップロードしていることがわかります。

動作確認する

デプロイされた結果です。

Lambdaコンソール

Lambda HandlerはToUpperに設定されます。通常C#の場合はNamespace.ClassName::MethodNameの形式ですが、ファイルベースの場合は名前空間やクラス名が省略され直接バイナリ名になります。

Lambda HandlerはToUpper

実行してみると、ToUpper関数が動作していることがわかります。テストイベントを以下のように設定します。

"foobar"

実行すると、以下のように返ってきます。

"FOOBAR"

Functionの実行結果

いい感じですね。

JSONを入力する

先ほどの例では文字列を受け取る単純なケースでしたが、実際のLambda関数ではJSONオブジェクトを扱うことが多いでしょう。 現在のコードのままJSONを与えると例外が発生します。

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

Lambdaを実行すると例外ログが出ます。

START RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9 Version: $LATEST
2026-01-14T06:09:44.113Z    08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9    fail    Amazon.Lambda.Serialization.SystemTextJson.JsonSerializerException: Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
 ---> System.Text.Json.JsonException: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
 ---> System.InvalidOperationException: Cannot get the value of a token type 'StartObject' as a string.
   at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_ExpectedString(JsonTokenType) + 0x19
   at System.Text.Json.Utf8JsonReader.GetString() + 0xa5
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader&, Type, JsonSerializerOptions, ReadStack&, T&, Boolean&) + 0x1e8
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader&, T&, JsonSerializerOptions, ReadStack&) + 0x81
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack&, Utf8JsonReader&, Exception) + 0x48
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader&, T&, JsonSerializerOptions, ReadStack&) + 0x21b
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.Deserialize(Utf8JsonReader&, ReadStack&) + 0x26
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1, JsonTypeInfo`1, Nullable`1) + 0xd6
   at Amazon.Lambda.Serialization.SystemTextJson.SourceGeneratorLambdaJsonSerializer`1.InternalDeserialize[T](Byte[]) + 0x78
   at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream) + 0x183
   --- End of inner exception stack trace ---
   at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream) + 0x229
   at Amazon.Lambda.RuntimeSupport.HandlerWrapper.<>c__DisplayClass44_0`2.<GetHandlerWrapper>b__0(InvocationRequest invocation) + 0x45
   at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.<>c__DisplayClass26_0.<<InvokeOnceAsync>b__0>d.MoveNext() + 0x1d5
END RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9
REPORT RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9  Duration: 55.06 ms  Billed Duration: 56 ms  Memory Size: 256 MB Max Memory Used: 37 MB

これに対応するには、入力JSONを表すC#クラスを用意し、シリアライズコンテキストに登録します。

[JsonSerializable(typeof(HelloWorldEvent))]
//[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

public record HelloWorldEvent
{
    [JsonPropertyName("key1")]
    public required string Key1 { get; init; }
    [JsonPropertyName("key2")]
    public required string Key2 { get; init; }
    [JsonPropertyName("key3")]
    public required string Key3 { get; init; }
}

受け取ったHelloWorldEventオブジェクトを使うようにハンドラーを書き換えます。

var handler = (HelloWorldEvent input, ILambdaContext context) =>
{
    return $"{input.Key1}, {input.Key2}, {input.Key3}".ToUpper();
};

変更後のToUpper.csのコードは次の通りです。

#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4

#:property TargetFramework=net10.0

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// 👇 HelloWorldEventを受け取るように書き換える
var handler = (HelloWorldEvent input, ILambdaContext context) =>
{
    return $"{input.Key1}, {input.Key2}, {input.Key3}".ToUpper();
};

await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
    .Build()
    .RunAsync();

// 👇 シリアライズコンテキストにHelloWorldEventを登録する
[JsonSerializable(typeof(HelloWorldEvent))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

// 👇 JSONを表すC#クラスを追加
public record HelloWorldEvent
{
    [JsonPropertyName("key1")]
    public required string Key1 { get; init; }
    [JsonPropertyName("key2")]
    public required string Key2 { get; init; }
    [JsonPropertyName("key3")]
    public required string Key3 { get; init; }
}

これで受け取ったJSONの値を大文字に変換して返せます。

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

JSONを受け取って大文字で返す

IaCからデプロイする

PulumiのAWS SDKでもファイルベースプログラムのC# Lambda関数をデプロイできます。 ただ、dotnet toolからのデプロイと違って明示的に事前ビルドが必要なので微妙です。

ガワだけIaCで作っておいてCodeはIgnore、関数自体は別途デプロイしたほうが扱いやすいです。

var name = "dotnet-file-based-lambda";
var functionName = "ToUpper";
var handlerName = "ToUpper";
var functionCodePath = "Lambda/DotnetFileAppFunction/artifacts/ToUpper/ToUpper"; // 事前にビルドしておいたバイナリのパス
var roleArn = "arn:aws:iam::123456789012:role/lambda-function-Role";

var lambdaHash = HashHelper.CreateHashSha256(functionCodePath);

var cloudwatchLogs = new Pulumi.Aws.CloudWatch.LogGroup($"{name}-lambda-loggroup", new()
{
    Name = $"/aws/lambda/{functionName}",
    RetentionInDays = 7,
}, opt);

var lambda = new Pulumi.Aws.Lambda.Function($"{name}-lambda-function", new()
{
    Name = functionName,
    Description = AwsConstants.Descriptions.Default,
    Code = new FileArchive(Directory.GetParent(functionCodePath)!.FullName.NormalizePath()),
    Role = roleArn,
    Runtime = "dotnet10",
    Handler = handlerName,
    Timeout = 10,
    SourceCodeHash = lambdaHash,
}, opt);

パフォーマンスについて

AWS Blogで、.NET 8に比べて.NET 10がパフォーマンス低下するケース(#120288)について言及されています。これに関しては、.NET 10.0.2で解消したとのことなので、安心できそうです。

.NET 10.0.2でパフォーマンス低下が改善

まとめ

AWS Lambdaが.NET 10をサポートしたことで、ファイルベースプログラムのC# Lambda関数が使えるようになりました。 .csファイルだけでLambda関数をデプロイできるので、C#でのLambda開発がかなり手軽になります。

C#のラムダ関数は、PythonやNode.jsに比べてビルドが必要で、C#を意識するのはお手軽じゃないと感じていました。PythonやNode.jsのような手軽さでC#が書けるのは嬉しい変化です。 .NET 10におけるファイルベースプログラムは割と良い感じなので、ぜひ試してみてください。

参考


  1. 私はコピー&ペーストで書いてる
  2. コマンドの途中に--function-architecture arm64を入れると回避できます
Image