GitHub ActionsCI/CDSecurity.NETDevOps

GitHub Actions krijgt een lockfile: zo zet je je .NET-pipeline vast

GitHub Actions ondersteunt sinds eind oktober workflow-lockfiles. Wat actions.lock wel en niet afdekt, hoe je het in een .NET-repo invoert en welke gaten je zelf moet dichten.

JB
Jean-Pierre Broeders
Freelance .NET Developer · 12 november 2026 · 11 min lezen
GitHub Actions krijgt een lockfile: zo zet je je .NET-pipeline vast

Je NuGet-packages staan waarschijnlijk al jaren vast in een packages.lock.json. De pipeline die die packages bouwt, ondertekent en met je productie-secrets naar Azure duwt, staat bij de meeste teams nog op @v4. Een tag dus, en een tag kan iedereen met schrijfrechten op die repo verplaatsen.

Gisteren stond Lock your GitHub Actions van Hugo van Kemenade hoog op Hacker News. Van Kemenade onderhoudt onder meer Pillow en werkt aan CPython, en hij laat stap voor stap zien hoe hij zijn repo's overzet op de workflow-lockfiles die GitHub op 28 oktober algemeen beschikbaar maakte. Ik heb het deze week bij twee klantrepo's ingevoerd. Hieronder wat het oplevert, waar het ophoudt en wat ik er in een .NET-repo omheen zet.

Waarom SHA-pinning nooit het hele verhaal was

Het oude advies kent iedereen: pin third-party actions op een volledige commit-SHA. Dat advies werd na maart 2025 ineens serieus genomen, toen bij tj-actions/changed-files bestaande versietags werden omgezet naar een kwaadaardige commit die secrets in de buildlogs dumpte. Wie op een tag zat, draaide de aanval mee. Wie op een SHA zat, niet.

Maar SHA-pinning heeft twee problemen waar ik bij elke klant tegenaan loop.

Het eerste is leesbaarheid. actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 werkt, maar die comment is een belofte. Niemand controleert of de SHA echt bij v7.0.1 hoort.

Het tweede is ernstiger. Een SHA pint alleen de action die jij aanroept. Is dat een composite action die intern zelf some-org/setup-thing@v2 gebruikt, dan draai je nog steeds op een verplaatsbare tag, alleen een niveau dieper. In de HN-draad noemt gebruiker hobofan het ontbreken van transitieve pinning "the biggest supply-chain security criticism" van GitHub Actions. Daar ben ik het mee eens.

Er is nog een derde, subtieler punt. GitHub resolvet een SHA ook als die commit alleen in een fork bestaat, omdat forks objecten delen met het origineel. Een aanvaller kan dus een commit in zijn fork zetten en die SHA laten lijken op iets uit de officiële repo. zizmor heeft daar al een aparte check voor (impostor commits). William Woodruff, de maker van zizmor, schrijft in de draad dat lockfiles "won't allow you to lock an impostor commit".

Wat actions.lock precies doet

Je installeert een extensie voor de GitHub CLI en draait die in de root van je repo:

gh extension install github/gh-actions-lock
gh actions-lock

Het commando herschrijft je workflows naar volledige versietags (van @v5 naar @v5.0.0, van een SHA met comment naar de tag zelf) en schrijft .github/workflows/actions.lock. Uit het voorbeeld van Van Kemenade ziet zo'n entry er zo uit:

dependencies:
    'actions/checkout@v7.0.1':
        ref: 'v7.0.1'
        commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
        owner_id: 44036562
        repo_id: 197814629

Let op owner_id en repo_id. Die zijn numeriek, dus een hernoemde of opnieuw geregistreerde repo met dezelfde naam valt door de mand.

De documentatie is helder over de handhaving. Een workflow die in de lockfile staat, heet "onboarded", en voor zo'n workflow moet elke action-dependency in de lockfile staan. De gelockte commit is wat er draait. Die commit moet bereikbaar zijn vanaf de branch of tag die in de lockfile staat, in de eigen repository van de action, anders wordt hij geweigerd. Transitieve dependencies binnen composite actions worden ook vastgelegd.

Gaat het mis, dan start de workflow niet eens. Een test van Classmethod toont een startup_failure met de melding Invalid lockfile toen ze actions/cache toevoegden zonder opnieuw te locken. Dat vind ik het goede gedrag. Er draait geen stap, dus er lekt ook niets.

Invoeren in een .NET-repo

Zo ziet de CI-workflow van een van die klantrepo's er nu uit. De lockfile-job is de variant die Van Kemenade gebruikt, gebaseerd op het verify-lockfile.yml-voorbeeld van GitHub:

name: CI

on: [push, pull_request]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-dotnet@v5.0.0
        with:
          global-json-file: global.json
      - run: dotnet restore --locked-mode
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release

  # Faalt als actions.lock niet meer klopt met de workflows,
  # of als een gelockte commit upstream niet meer verifieert.
  lockfile:
    runs-on: ubuntu-latest
    env:
      GH_TOKEN: ${{ github.token }}
    steps:
      - uses: actions/checkout@v7.0.1
        with:
          persist-credentials: false
      - run: gh extension install github/gh-actions-lock --pin v0.4.1
      - run: gh actions-lock --verify

De versienummers van de actions zijn wat de tool bij mij neerzette. Bij jou kunnen ze hoger zijn.

Twee dingen vallen op. De verify-job is een aparte job, zodat hij parallel loopt en een rode lockfile niet verstopt raakt tussen falende tests. En de extensie zelf staat op --pin v0.4.1. Van Kemenade merkt dat zelf ook op: die versie moet je ooit handmatig ophogen. Het is een klein gat, maar het is er.

Wat ik er in .NET-repo's altijd naast zet, is de NuGet-kant. dotnet restore --locked-mode faalt als packages.lock.json niet klopt met je projectbestanden. Dat werkt alleen als de lockfiles er zijn, dus in Directory.Build.props:

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

Draai daarna een keer lokaal dotnet restore en commit alle packages.lock.json-bestanden. Nu zijn beide lagen vastgezet: de code die je bouwt en de code die het bouwen doet.

Dependabot, met een cooldown

Een lockfile die nooit bijgewerkt wordt, is na een halfjaar een lijst bekende kwetsbaarheden. Volgens de changelog regenereert Dependabot de bijbehorende lockfile-entry in dezelfde pull request als het een action bijwerkt. Renovate ondersteunt het ook.

Van Kemenade geeft er een advies bij dat ik graag herhaal: gebruik cooldowns. Bij de meeste supply-chain-aanvallen van de afgelopen jaren werd de kwaadaardige versie binnen dagen ontdekt en ingetrokken. Wie een week wacht met updaten, mist die versies bijna altijd.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7

  - package-ecosystem: "nuget"
    directory: "/"
    schedule:
      interval: "weekly"
    cooldown:
      default-days: 7

Voor security-updates geldt de cooldown niet. Een gepatchte CVE wil je wel meteen.

Wat de lockfile niet afdekt

Hier wordt het interessant, en hier gaat de HN-discussie terecht over. GitHub zegt het zelf in de changelog: lockfiles "help protect against unexpected dependency changes, but they don't determine whether a dependency is safe." Een action die vandaag kwaadaardig is, lock je net zo makkelijk vast als een goede.

Daarnaast zijn er concrete gaten.

Reusable workflows vallen erbuiten. Een aanroep als jobs.deploy.uses: mijn-org/platform/.github/workflows/deploy.yml@v3 wordt niet gepind. De dependencies binnen die workflow worden gedekt door de lockfile van de repo waar hij staat, niet door die van jou. Wil je de aanroep zelf vastzetten, dan blijft het een SHA.

Container-images ook niet. container:, services: en docker://-actions verwijzen naar images, en de lockfile gaat over Git-commits. Een mcr.microsoft.com/mssql/server:2022-latest als service-container voor je integratietests verandert gewoon onder je door. Pin op digest.

En alles wat een action tijdens het draaien zelf ophaalt, ziet de lockfile niet. Woodruff noemt in de draad npx-aanroepen als voorbeeld. In .NET-land is dat een dotnet tool install -g dotnet-ef zonder --version, of een script dat de nieuwste release van iets downloadt. De documentatie noemt verder inputs, secrets en het runner-image als dingen die de lockfile niet beheert.

Die gaten kun je wel zelf zichtbaar maken. Ik heb er een klein script voor dat als file-based app draait op .NET 10, zonder projectbestand:

// audit-workflows.cs, draai met: dotnet run audit-workflows.cs
using System.Text.RegularExpressions;

var root = args.Length > 0 ? args[0] : ".github/workflows";
var fullSha = new Regex("^[0-9a-f]{40}$");
var usesLine = new Regex(@"^\s*(?:-\s*)?uses:\s*['""]?(?<ref>[^'""\s#]+)");
var imageLine = new Regex(@"^\s*(?:-\s*)?(?:image|container):\s*['""]?(?<img>[^'""\s#]+)");
var findings = new List<string>();

foreach (var file in Directory.EnumerateFiles(root, "*.y*ml"))
{
    var lines = File.ReadAllLines(file);
    for (var i = 0; i < lines.Length; i++)
    {
        var where = $"{Path.GetFileName(file)}:{i + 1}";

        var uses = usesLine.Match(lines[i]);
        if (uses.Success)
        {
            var target = uses.Groups["ref"].Value;
            if (target.StartsWith("docker://"))
            {
                if (!target.Contains("@sha256:"))
                    findings.Add($"{where}  docker-action zonder digest: {target}");
            }
            else if (target.Contains(".github/workflows/") && !target.StartsWith("./"))
            {
                var at = target.LastIndexOf('@');
                if (at < 0 || !fullSha.IsMatch(target[(at + 1)..]))
                    findings.Add($"{where}  reusable workflow niet op SHA: {target}");
            }
            continue;
        }

        var image = imageLine.Match(lines[i]);
        if (image.Success && !image.Groups["img"].Value.Contains("@sha256:"))
            findings.Add($"{where}  image zonder digest: {image.Groups["img"].Value}");
    }
}

findings.ForEach(Console.WriteLine);
Console.WriteLine(findings.Count == 0
    ? "Geen gaten buiten de lockfile gevonden."
    : $"{findings.Count} verwijzing(en) die actions.lock niet afdekt.");
return findings.Count == 0 ? 0 : 1;

Het is een heuristiek op regelniveau, geen YAML-parser. Een container: met een geneste image: eronder pakt hij via die tweede regel, en lokale workflows (./.github/workflows/...) slaat hij over omdat die in je eigen repo staan. Exitcode 1 bij bevindingen, dus je kunt hem als stap in dezelfde lockfile-job hangen. Wat hij vindt, los je zo op:

jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      sql:
        image: mcr.microsoft.com/mssql/server:2022-latest@sha256:<digest>

  deploy:
    needs: integration
    uses: mijn-org/platform/.github/workflows/deploy.yml@<volledige-sha> # v3.2.0

De digest haal je op met docker buildx imagetools inspect op de tag die je wilt. En ja, daar staat de onleesbare SHA met comment weer. Voor reusable workflows is er nog geen beter alternatief.

Waar het in de praktijk schuurt

De migratie zelf kostte per repo een kwartier. De randzaken kosten meer.

Heb je in je organisatie een allowlist van toegestane actions, dan breekt die. Classmethod beschrijft dat entries als actions/checkout@v5 niet meer matchen na onboarding. Je moet naar wildcards of volledige SHA's. Dat moet een org-admin doen, en die zit niet altijd in je team.

De extensie beweegt ook nog. Volgens hetzelfde artikel rapporteren lockfiles die met v0.2.0 zijn gemaakt als onvolledig onder v0.3.0, en vroeg v0.4.0 om een nieuwe vlag voor lokale actions. Een tool op versie 0.x in je verplichte CI-check betekent dat je af en toe moet relocken. Dat accepteer ik, maar plan het in.

En voor zizmor-gebruikers: pas vanaf v1.32.0 begrijpt zizmor lockfiles. Oudere versies blijven klagen dat je actions niet op SHA staan, terwijl je ze net bewust naar tags hebt teruggezet.

Wil je het afdwingen, dan heeft GitHub een "Require lockfile"-policy op enterprise-, organisatie- en repo-niveau. Bij klanten zet ik die pas aan als alle repo's over zijn. Anders blokkeer je teams die er nog niet aan toe zijn.

Mijn advies

Doe het. Voor een .NET-team dat NuGet al lockt, is dit het ontbrekende stuk: de pipeline heeft toegang tot je signing-certificaat, je publish-profielen en via OIDC tot je Azure-subscription, en die verdient minstens dezelfde discipline als je dependencies. Transitieve pinning en de bescherming tegen impostor commits zijn echte winst boven handmatige SHA's.

Wel heeft actions.lock duidelijke grenzen. Reusable workflows, images en alles wat at runtime binnenkomt, moet je zelf blijven vastzetten. Een check als het script hierboven maakt zichtbaar waar dat nog niet gebeurt.

Veelgestelde vragen over GitHub Actions-lockfiles

Moet ik mijn bestaande SHA-pins eerst zelf terugzetten naar tags? Nee, gh actions-lock doet dat voor je: in het voorbeeld van Van Kemenade werd actions/checkout@<sha> # v7.0.1 automatisch actions/checkout@v7.0.1, met de commit vastgelegd in actions.lock. Lees de diff wel na voordat je commit.

Werken lockfiles ook op GitHub Enterprise Server? De changelog van 28 oktober noemt GitHub Enterprise Cloud (ook met data residency), Team en Free. Enterprise Server staat er niet bij, dus reken er op een self-hosted GHES-installatie voorlopig niet op en houd daar SHA-pinning aan.

Wat gebeurt er met workflows die ik nog niet heb onboarded? Die draaien zoals voorheen, met hun tags of SHA's, omdat GitHub onboarding per workflow toestaat. Pas als een admin de policy "Require lockfile" aanzet, wordt de lockfile verplicht.

Vervangt dit een tool als zizmor? Nee, zizmor kijkt naar veel meer dan pinning, zoals template-injectie, te ruime permissions en persist-credentials. Zorg wel dat je minstens v1.32.0 draait, anders krijg je valse meldingen over actions die niet op een SHA staan.

Is een lockfile zinvol voor een kleine repo met alleen actions van GitHub zelf? Ja, ook actions/* kan intern dependencies hebben en een gecompromitteerd account treft ook officiële repo's. De kosten zijn een paar minuten en een extra job van enkele seconden per run.

Verder lezen


Conclusie: Workflow-lockfiles maken van je GitHub Actions-pipeline wat packages.lock.json al jaren voor je NuGet-dependencies is. Voer ze in, laat Dependabot ze met een cooldown bijwerken en controleer zelf de reusable workflows en container-images, want daar komt de lockfile niet.

Bronnen: Hugo van Kemenade: Lock your GitHub Actions · Hacker News-discussie · GitHub Changelog: workflow lockfiles GA · GitHub Docs: workflow dependency lockfiles.

Hulp nodig bij een vergelijkbaar vraagstuk?
Neem contact op