Debian maakt reproduceerbare builds verplicht. Wat betekent dat voor .NET?

Debian maakt reproduceerbare builds verplicht voor zijn pakketten. Het is het sterkste supply-chain-signaal in jaren — en de .NET-toolchain is er beter op voorbereid dan de meeste mensen denken. Zo maak je je builds byte-voor-byte reproduceerbaar en bewijs je het in CI.

Jean-Pierre Broeders

Freelance .NET Developer

8 juni 202610 min. leestijd
Debian maakt reproduceerbare builds verplicht. Wat betekent dat voor .NET?

Debian maakt reproduceerbare builds verplicht. Wat betekent dat voor .NET?

Een kort berichtje van het Debian release-team klom deze week naar de frontpage van Hacker News (de aankondiging zelf), en het is een grotere zaak dan zijn platte-tekst-opmaak doet vermoeden. Sinds 9 mei 2026 blokkeert Debians migratie-tooling elk nieuw pakket dat niet reproduceerbaar is, en blokkeert het elk pakket dat al in testing zit en terugvalt in reproduceerbaarheid. Debian 14 "Forky", verwacht in 2027, wordt de eerste grote algemene Linux-distributie die reproduceerbare builds over de hele linie verplicht stelt. Ten tijde van de aankondiging reproduceerde al 98,29% van de architectuur-onafhankelijke pakketten — 23.731 geslaagd, 414 als slecht gemarkeerd.

Dat getal doet ertoe, want Debian is de upstream voor meer dan 120 afgeleide distributies, waaronder Ubuntu, Mint, Kali, Raspberry Pi OS en Tails. Wanneer Debians testing-branch reproduceerbaarheid afdwingt, erft alles downstream die garantie. Dit is de grootste duw richting verifieerbare builds die de open-sourcewereld ooit gezien heeft.

Ik wil dit uit de Linux-packaging-context halen en de vraag stellen die mij als .NET-freelancer écht raakt: als reproduceerbare builds de norm worden voor het distribueren van software, hoe klaar is de .NET-toolchain dan, en wat moet ik doen om er te komen? De aangename verrassing is dat .NET verder is dan de meesten aannemen. De onaangename verrassing is dat "standaard deterministisch" en "echt reproduceerbaar" niet hetzelfde zijn, en dat het gat daartussen precies de plek is waar supply-chain-bugs zich verschuilen.

Wat reproduceerbaar eigenlijk betekent

Een build is reproduceerbaar als het compileren van dezelfde broncode, in dezelfde gedefinieerde omgeving, altijd exact dezelfde bytes oplevert — elke keer, op elke machine. Dat is een sterkere eigenschap dan "deterministisch". Determinisme betekent dat de compiler een pure functie is van zijn invoer. Reproduceerbaarheid betekent dat de hele build dat is, inclusief elke omgevingsvariabele die in de output sluipt: absolute bestandspaden, ingebakken timestamps, de volgorde waarin het bestandssysteem je bestanden aanlevert, locale, en de compilerversie zelf.

Waarom de moeite? Omdat reproduceerbaarheid is wat een derde partij in staat stelt te verifiëren dat een gepubliceerd binary daadwerkelijk uit de gepubliceerde broncode komt. Zonder dat vertrouw je erop dat wie de build draaide niets heeft tussengevoegd tussen git clone en dotnet publish — en de xz-backdoor liet precies zien hoe dat vertrouwen misbruikt wordt. Een reproduceerbare build verandert "vertrouw me" in "controleer het zelf": iedereen herbouwt, vergelijkt de hashes, en óf ze matchen óf iemand heeft wat uit te leggen.

.NET is standaard deterministisch — en dat is niet genoeg

Hier gaat het mis bij mensen. Modern .NET zet <Deterministic>true</Deterministic> standaard aan. De Roslyn-compiler levert, gegeven identieke invoer, byte-voor-byte identieke output: hij vervangt de timestamp en de MVID (de ModuleVersionId van de module, normaal een willekeurige GUID) door waarden afgeleid van een hash van alle compilatie-invoer. Tot zover prima.

Maar "identieke invoer" doet veel werk in die zin. De compiler rekent het huidige directorypad mee als invoer. En standaard bakken je assembly en de bijbehorende PDB de absolute bronbestandspaden in. Bouw dezelfde commit op C:\DEV\myapp en op /home/runner/work/myapp, en je krijgt andere bytes — niet omdat de code verschilt, maar omdat de paden in de debug-informatie verschillen. Je lokale build en je CI-build matchen niet, en die van twee ontwikkelaars onderling evenmin.

Dat gat dicht ContinuousIntegrationBuild. Op true normaliseert het de opgeslagen bestandspaden, zodat de absolute buildlocatie niet meer in de output lekt:

<Project>
  <PropertyGroup>
    <Deterministic>true</Deterministic>
    <!-- Zet padnormalisatie alleen aan op CI, waar je de debugger
         geen lokale bronpaden hoeft te laten vinden. -->
    <ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
  </PropertyGroup>
</Project>

Let op de conditie. Je zet dit bewust niet aan voor lokale debug-builds, want padnormalisatie breekt het vermogen van de debugger om je broncode op schijf te vinden. Op de CI-server, waar niemand door code stapt, wil je het juist aan hebben. Voor Azure Pipelines is de variabele TF_BUILD; voor GitHub Actions is het GITHUB_ACTIONS. Zet dit in een Directory.Build.props in de root van de repo, zodat elk project het erft zonder dat jij eraan hoeft te denken.

SourceLink: traceerbaarheid gratis, met één kanttekening

Sinds .NET 8 zit SourceLink in de SDK. Elke build bakt nu de source-control-commit in de InformationalVersion, zodat een binary de exacte SHA draagt waarvan hij gebouwd is — 1.4.0+a1b2c3d. Dat is echt nuttig: gegeven een mysterieuze DLL in productie lees je de commit er zo van af.

Het interacteert wel op een manier met reproduceerbaarheid die het waard is te begrijpen. SourceLink-data — de commit-SHA, de repo-URL — is zelf compilatie-invoer. Dat is prima en zelfs wenselijk: twee builds van dezelfde commit bakken dezelfde SHA in, dus ze blijven reproduceerbaar. De valkuil is als je iets niet-deterministisch in de versie injecteert, met als meest voorkomende dader een buildnummer of een van een timestamp afgeleide versie:

<!-- NIET DOEN: een timestamp maakt elke build met opzet uniek -->
<Version>1.4.0-ci$([System.DateTime]::Now.ToString("yyyyMMddHHmmss"))</Version>

Als je versie-string elke build verandert, verandert je output elke build, en is reproduceerbaarheid bij voorbaat weg. Stuur de versie aan vanuit de commit of de tag, niet vanuit de klok. Heb je buildmetadata nodig, zet die dan op een plek die de compiler niet voedt — een release-note, een OCI-image-label, een provenance-attestatie — niet de assembly-versie.

Een reproduceerbare publish in de praktijk

Alles bij elkaar: dit is het recept dat ik nu toepas op alles wat ik distribueer. De projectproperties:

<!-- Directory.Build.props -->
<Project>
  <PropertyGroup>
    <Deterministic>true</Deterministic>
    <ContinuousIntegrationBuild Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
    <!-- Bak bronnen in die de compiler anders niet kan vinden, zodat de PDB self-contained is -->
    <EmbedUntrackedSources>true</EmbedUntrackedSources>
    <DebugType>portable</DebugType>
    <!-- Pin de SDK, zodat de compilerversie zelf een invoer is die jij beheerst -->
  </PropertyGroup>
</Project>

En een global.json die de SDK vastpint, want de precieze compilerversie is onderdeel van de invoerset — een andere SDK is een andere build:

{
  "sdk": {
    "version": "9.0.301",
    "rollForward": "disable"
  }
}

Dan de bewijsstap. Reproduceerbaarheid die je niet verifieert is gewoon een hoopvol commentaar. De goedkoopst denkbare check is het artefact tweemaal bouwen en de hashes vergelijken:

#!/usr/bin/env bash
set -euo pipefail

dotnet publish -c Release -o ./out-1 /p:ContinuousIntegrationBuild=true
dotnet publish -c Release -o ./out-2 /p:ContinuousIntegrationBuild=true

# Vergelijk elk geproduceerd bestand op content-hash.
diff <(cd out-1 && find . -type f -exec sha256sum {} \; | sort) \
     <(cd out-2 && find . -type f -exec sha256sum {} \; | sort) \
  && echo "Reproduceerbaar ✅" \
  || { echo "NIET-reproduceerbaar ❌"; exit 1; }

Twee builds op dezelfde runner vangen het voor de hand liggende niet-deterministische gedrag — ingebakken timestamps, willekeurige GUID's, de versie-string-fout hierboven. Om pad-lekkage te vangen heb je de strengere test nodig: bouw op twee werkelijk verschillende paden en vergelijk. Daar komen de echte bugs boven, en het is een aparte CI-job waard:

# .github/workflows/reproducible.yml
name: reproducible-build
on: [push]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
      - name: Build op pad A
        run: dotnet publish src/MyApp -c Release -o /tmp/a /p:ContinuousIntegrationBuild=true
      - name: Build op pad B
        run: |
          cp -r . /tmp/repo-b && cd /tmp/repo-b
          dotnet publish src/MyApp -c Release -o /tmp/b /p:ContinuousIntegrationBuild=true
      - name: Vergelijk
        run: |
          diff <(cd /tmp/a && find . -type f -exec sha256sum {} \; | sort) \
               <(cd /tmp/b && find . -type f -exec sha256sum {} \; | sort)

Is die job groen, dan heb je iets dat echt het distribueren waard is: een binary die iedereen onafhankelijk kan herbouwen en controleren.

De containerlaag is waar het meestal misgaat

De meeste .NET-teams shippen geen kale DLL; ze shippen een container-image. En op het moment dat je een reproduceerbare assembly in een Dockerfile wikkelt, kun je elk niet-deterministisch gedrag dat je net elimineerde weer binnenhalen. apt-get trekt binnen wat vandaag de huidige versie is. De aanmaak-timestamp van het image verandert elke build. Bestandsvolgorde in een laag hangt af van de buildcontext. Dus de assembly-hashes matchen, maar de image-digests nooit.

Een deel hiervan is met discipline op te lossen. Pin base-images op digest, niet op tag:

# Pin op digest, zodat "8.0" niet stilletjes onder je verandert
FROM mcr.microsoft.com/dotnet/aspnet@sha256:abc123...

Het timestamp-probleem heeft een standaardantwoord waar de reproducible-builds-community jaren geleden op convergeerde: de omgevingsvariabele SOURCE_DATE_EPOCH, die buildtools respecteren als het canonieke "nu". BuildKit respecteert hem voor laag-timestamps via --build-arg SOURCE_DATE_EPOCH=... in combinatie met rewrite-timestamp. Zet hem vanuit de commitdatum, zodat hij stabiel is per commit:

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker buildx build \
  --build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH \
  --output type=image,name=myapp,rewrite-timestamp=true .

Wees eerlijk tegen jezelf over hoe ver je dit moet doortrekken. End-to-end reproduceerbare container-images zijn echt werk, en voor een interne line-of-business-service weegt de opbrengst dat misschien niet op. Voor alles wat je aan anderen publiceert — een base-image, een open-sourcetool, een pakket waar andere teams van afhangen — steeds vaker wel.

SLSA: van "reproduceerbaar" naar "aantoonbaar hier gebouwd"

Reproduceerbaarheid beantwoordt "matcht dit binary de broncode?". De complementaire vraag is "waar en hoe is het gebouwd?", en dat beantwoordt build-provenance. Het SLSA-framework formaliseert dit, en GitHub Actions maakt het basisniveau bijna gratis met de build-provenance-attestatie-action:

permissions:
  id-token: write
  attestations: write
  contents: read
steps:
  - uses: actions/attest-build-provenance@v1
    with:
      subject-path: '/tmp/a/MyApp.dll'

Dit produceert een ondertekende, verifieerbare verklaring dat dit artefact door deze workflow uit deze commit gebouwd is. Combineer het met een reproduceerbare build en je hebt beide helften van het vertrouwensverhaal: provenance zegt waar het vandaan komt, reproduceerbaarheid laat iedereen bevestigen dat de provenance niet liegt.

De conclusie

Debians mandaat is een signaal, geen op zichzelf staand incident. Reproduceerbare builds verschuiven van een curiositeit voor security-onderzoekers naar een basisverwachting voor iedereen die software distribueert, en de regelgevingsdruk rond software-supply-chains duwt dezelfde kant op. Het goede nieuws voor .NET-ontwikkelaars is dat de toolchain stilletjes het meeste zware werk al gedaan heeft: deterministische compilatie staat standaard aan, SourceLink zit in de doos, en ContinuousIntegrationBuild dicht het ene gat dat iedereen pijn doet. Wat resteert is grotendeels discipline — pin je SDK, stuur versies aan vanuit commits en niet vanuit de klok, en voeg een CI-job toe die daadwerkelijk herbouwt en vergelijkt.

Begin met de twee-paden-vergelijkingsjob. Het kost een middag, hij faalt waarschijnlijk de eerste keer, en de reden waarom hij faalt leert je precies welk deel van je build liegt over reproduceerbaar zijn. Lees de Debian-aankondiging en de HN-discussie voor de bredere context — en maak daarna van je eigen builds iets dat een vreemde zou kunnen verifiëren.

Wil je op de hoogte blijven?

Schrijf je in voor mijn nieuwsbrief of neem contact op voor freelance projecten.

Neem Contact Op