From 34c0785a5c9fcb9cdeae153b746098d05fb388df Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Fri, 14 Aug 2026 04:26:06 +0300 Subject: [PATCH 1/2] Raise SSH.NET past the advisory Testcontainers drags in `Testcontainers` 4.13.0 resolves `SSH.NET` 2025.1.0, which is GHSA-q939-rpr3-3284, high severity: ScpClient's recursive download writes wherever a malicious server's filenames point. Every restore has been reporting it as NU1903, on both target frameworks. It is a test-only path and this repository does not treat warnings as errors, so nothing was failing -- it was just being printed and ignored. Patched in 2026.0.0, and 4.13.0 is Testcontainers' own latest release, so there is nothing upstream to wait for. Raised by referencing it directly from Healthie.Tests.Unit rather than by turning on CentralPackageTransitivePinningEnabled. That switch pins one version of every transitive package across the solution, which collides with the per-target-framework Microsoft.Extensions.* versions this file deliberately keeps apart: enabling it fails restore with 34 NU1109 downgrade errors. Verified both ways round. `dotnet restore --locked-mode` and `dotnet build -c Release` now finish with no warnings at all, where they previously printed two. --- Directory.Packages.props | 12 ++++++ .../Healthie.Tests.Unit.csproj | 1 + tests/Healthie.Tests.Unit/packages.lock.json | 42 ++++++++++--------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 067ffd4..baaa5ee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -73,6 +73,18 @@ + + diff --git a/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj b/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj index 49b2c01..6176a90 100644 --- a/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj +++ b/tests/Healthie.Tests.Unit/Healthie.Tests.Unit.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Healthie.Tests.Unit/packages.lock.json b/tests/Healthie.Tests.Unit/packages.lock.json index 2a86fff..de1ac6c 100644 --- a/tests/Healthie.Tests.Unit/packages.lock.json +++ b/tests/Healthie.Tests.Unit/packages.lock.json @@ -31,6 +31,15 @@ "resolved": "13.0.4", "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" }, + "SSH.NET": { + "type": "Direct", + "requested": "[2026.0.0, )", + "resolved": "2026.0.0", + "contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==", + "dependencies": { + "BouncyCastle.Cryptography": "2.7.0" + } + }, "Testcontainers.CosmosDb": { "type": "Direct", "requested": "[4.13.0, )", @@ -116,8 +125,8 @@ }, "BouncyCastle.Cryptography": { "type": "Transitive", - "resolved": "2.6.2", - "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + "resolved": "2.7.0", + "contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw==" }, "Docker.DotNet.Enhanced": { "type": "Transitive", @@ -399,14 +408,6 @@ "SQLitePCLRaw.core": "2.1.12" } }, - "SSH.NET": { - "type": "Transitive", - "resolved": "2025.1.0", - "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", - "dependencies": { - "BouncyCastle.Cryptography": "2.6.2" - } - }, "System.ClientModel": { "type": "Transitive", "resolved": "1.1.0", @@ -831,6 +832,15 @@ "resolved": "13.0.4", "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" }, + "SSH.NET": { + "type": "Direct", + "requested": "[2026.0.0, )", + "resolved": "2026.0.0", + "contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==", + "dependencies": { + "BouncyCastle.Cryptography": "2.7.0" + } + }, "Testcontainers.CosmosDb": { "type": "Direct", "requested": "[4.13.0, )", @@ -916,8 +926,8 @@ }, "BouncyCastle.Cryptography": { "type": "Transitive", - "resolved": "2.6.2", - "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + "resolved": "2.7.0", + "contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw==" }, "Docker.DotNet.Enhanced": { "type": "Transitive", @@ -1312,14 +1322,6 @@ "SQLitePCLRaw.core": "2.1.12" } }, - "SSH.NET": { - "type": "Transitive", - "resolved": "2025.1.0", - "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", - "dependencies": { - "BouncyCastle.Cryptography": "2.6.2" - } - }, "System.ClientModel": { "type": "Transitive", "resolved": "1.1.0", From 88273d476f4b857b784fec9e70bd5b84dd8d1a56 Mon Sep 17 00:00:00 2001 From: Ivan Vydrin Date: Fri, 14 Aug 2026 07:21:55 +0300 Subject: [PATCH 2/2] Give the lock-file chore a button, and a failure that names it Dependabot edits Directory.Packages.props but cannot rewrite packages.lock.json: the CentralTransitive entries have to be resolved by NuGet rather than pattern-matched. So every NuGet bump lands with stale lock files and dies at `dotnet restore --locked-mode` with NU1004, before anything is built. Three changes, none of which give a bot write access: - `Refresh lock files`, a workflow_dispatch job that regenerates the lock files on a named branch, proves the result with `--locked-mode` and a Release build, and commits only `**/packages.lock.json`. `contents: write` is scoped to that one job. - CI restore is named and, on failure, says which workflow to run and on which branch. NU1004 does not mention the fix itself. - The constraint is written beside the nuget ecosystem in dependabot.yml. Deliberately a button rather than an automatic push on every Dependabot PR: a push made with GITHUB_TOKEN does not re-trigger workflows, so an automatic version would commit the fix and leave the required checks pinned to the superseded commit. Making it automatic needs a PAT or GitHub App key, which these repositories avoid by design. Verified on LakeWright.NET: green end to end against two live Dependabot branches, and it correctly declined to commit when the locks already matched. --- .github/dependabot.yml | 5 ++ .github/workflows/ci.yml | 10 ++- .github/workflows/refresh-lock-files.yml | 81 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/refresh-lock-files.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 08f1903..3186e03 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,6 +3,11 @@ updates: # Central package management means all NuGet versions live in # Directory.Packages.props — Dependabot updates that file automatically, # no per-project configuration needed. + # NOTE: Dependabot updates Directory.Packages.props but cannot rewrite packages.lock.json -- + # the CentralTransitive entries have to be resolved by NuGet, not pattern-matched. Every bump + # here therefore arrives with stale lock files and fails `dotnet restore --locked-mode` (NU1004) + # until they are regenerated. Run the "Refresh lock files" workflow against the bump's branch; + # .github/workflows/refresh-lock-files.yml explains why that is a button and not automatic. - package-ecosystem: "nuget" directory: "/" schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d544fd..f608c24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,16 @@ jobs: 8.0.x 10.0.x + # Named, so the failure can say what to do about it. NU1004 on a Dependabot branch means + # the lock files were not regenerated, which is a chore rather than a defect -- and the + # error NuGet prints for it does not mention the fix. - name: Restore - run: dotnet restore --locked-mode + run: | + set -euo pipefail + if ! dotnet restore --locked-mode; then + echo "::error title=Lock files are out of step::dotnet restore --locked-mode failed. If this is a dependency bump, Dependabot cannot rewrite packages.lock.json under central package management. Run the 'Refresh lock files' workflow against ${{ github.head_ref || github.ref_name }}, then re-run these checks." + exit 1 + fi - name: Build run: dotnet build --configuration Release --no-restore /p:ContinuousIntegrationBuild=true diff --git a/.github/workflows/refresh-lock-files.yml b/.github/workflows/refresh-lock-files.yml new file mode 100644 index 0000000..d375e5c --- /dev/null +++ b/.github/workflows/refresh-lock-files.yml @@ -0,0 +1,81 @@ +name: Refresh lock files + +# Dependabot cannot do this itself. +# +# Under central package management it edits Directory.Packages.props, but it does not rewrite +# packages.lock.json -- and it cannot, because the CentralTransitive entries have to be resolved by +# NuGet, not pattern-matched. So every NuGet bump arrives with lock files that still name the old +# versions, and `dotnet restore --locked-mode` fails before anything builds: +# +# error NU1004: Mistmatch between the requestedVersion of a lock file dependency marked as +# CentralTransitive and the version specified in the central package management file. +# +# Run this against the bump's branch and it fixes itself. +# +# Deliberately `workflow_dispatch` and not an automatic push on every Dependabot PR. A push made +# with GITHUB_TOKEN does not re-trigger workflows, so an automatic version would commit the fix and +# leave the required checks pinned to the superseded commit -- one stall traded for another. Making +# that work needs a PAT or a GitHub App key, and this repository stores no such credential on +# purpose. A human runs this, then re-runs the checks. + +on: + workflow_dispatch: + inputs: + branch: + description: The branch to refresh, e.g. dependabot/nuget/microsoft-extensions-abc123 + required: true + type: string + +permissions: + contents: read + +concurrency: + group: refresh-lock-files-${{ inputs.branch }} + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # The one job in this repository that writes. Scoped here rather than at the top of the file + # so nothing else in this workflow inherits it. + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ inputs.branch }} + persist-credentials: true + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 10.0.x + + # --force-evaluate is the whole point: it re-resolves rather than asserting the lock is right. + - run: dotnet restore --force-evaluate + + # Prove the result before committing it. If locked-mode cannot restore what force-evaluate + # just wrote, something is wrong that a commit would only bury. + - run: dotnet restore --locked-mode + - run: dotnet build --no-restore -c Release + + - name: Commit the refreshed lock files + run: | + set -euo pipefail + # Named paths, never `git add -A`: this job can write, so it says exactly what it writes. + if [ -z "$(git status --porcelain -- '*packages.lock.json')" ]; then + echo "Lock files already match the manifests. Nothing to commit." + exit 0 + fi + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -- '*packages.lock.json' + git commit -m 'Refresh the lock files for this bump + + Regenerated with `dotnet restore --force-evaluate`, then verified with + `--locked-mode` and a Release build. Dependabot cannot write these under + central package management; see .github/workflows/refresh-lock-files.yml.' + git push origin HEAD:'${{ inputs.branch }}' + echo "Pushed. Re-run the pull request's checks -- a GITHUB_TOKEN push does not."