Building an SBOM for an AOT .net Docker

What’s in a Docker image? Nobody knows! Unless, you use an SBOM. What is an SBOM you say? Source Bill of Materials is a file containing all of the resources and tools that were used to build or are included in a software release artifact. SBOMs can be used to identify vulnerable software or even ensure compliance with software licenses. Imagine being able to block vulnerable software using a Kyverno policy.

I wanted to generate a Docker image that contained .net AOT compiled application and also generate an SBOM. Unfortunately, it wasn’t as straight forward as generating it from the output Docker image because it didn’t contain any build packages or any dotnet packages.

Additionally, the build tools have just as much of a role to play in software security as the libraries that end up in the artifact. For example, there have been several supply chain attacks that injected code into a binary or modified release artifacts using stole tokens. An SBOM should contain the build tools too, which simply running Syft won’t give you.

The Problem

This is a pretty standard Dockerfile that the C#/.net application template will generate for you. It uses a Dockerfile multi-stage build to build and publish (which performs the AOT, ahead-of-time compilation), then it copies the binary into a final image. In my case, I’m using the chiseled which contained only 9 packages. dotnet/runtime:10.0 contained slightly more at 98 packages

Starting Dockerfile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["LLMProxy/LLMProxy.csproj", "LLMProxy/"]
RUN dotnet restore "LLMProxy/LLMProxy.csproj"
COPY . .
WORKDIR "/src/LLMProxy"
RUN dotnet build "./LLMProxy.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release  
RUN apt-get update \  
    && apt-get install -y --no-install-recommends \  
    clang zlib1g-dev
RUN dotnet publish "./PlaidSync.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
  
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "LLMProxy.dll"]

Example Syft output

Tools like syft are able to scan an image and identify packages, such as debian apt packages, but an AOT compiled .net application is slimmed down.

Running syft on the output of the above Dockerfile gives me:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
NAME             VERSION                  TYPE    
base-files       13ubuntu10.4             deb     
ca-certificates  20240203                 deb     
gcc-14           14.2.0-4ubuntu2~24.04.1  deb     
gcc-14-base      14.2.0-4ubuntu2~24.04.1  deb     
libc6            2.39-0ubuntu8.7          deb     
libgcc-s1        14.2.0-4ubuntu2~24.04.1  deb     
libssl3t64       3.0.13-0ubuntu3.9        deb     
libstdc++6       14.2.0-4ubuntu2~24.04.1  deb     
openssl          3.0.13-0ubuntu3.9        deb     

None of my application dependencies are in here. If any of them had a report security vulnerability, I wouldn’t be able to detect it. An AOT compiled application removes all of the indicators that Syft and other scanners need to know what your dependencies look like.

Capturing dependencies during the build

How can we fix this problem? We have to capture the dependencies during the build stage. The compiler sees all the dependencies during the build target above, but it gets lost. I’m using Forgejo Actions workflows, which is modeled very similarly to GitHub actions workflows

Attempt 1 - Using Syft

My first attempt using Syft, I built the image to the build target, then ran Syft on that output:

1
2
3
docker build --target=build -t test .

syft docker:test

I got 2,322 dotnet packages + 7k other, non-dotnet packages. Do we have success?!

NAMEVERSIONTYPE
Json.NET13.0.3.27908dotnet (+2 duplicates)
Microsoft.CodeAnalysis.CSharp4.13.0dotnet
Microsoft.CodeAnalysis.CSharp5.3.0-2.26064.108dotnet
Microsoft.CodeAnalysis.CSharp5.3.0-2.26153.122dotnet (+7 duplicates)

Unfortunately, not yet. Syft is detecting all the dotnet libraries that exist in the image regardless of whether or not I use them. In addition, it can’t identify what versions I’m actually using and shows multiple versions for the same library.

I need the SBOM to be generated based on what my project actually depends on.

The .csproj file does contain package references as the below shows, however it only includes direct dependencies, no transitive dependencies, and isn’t guaranteed to be exactly the same each time.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<Project Sdk="Microsoft.NET.Sdk.Web">
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
    <PackageReference Include="NSwag.ApiDescription.Client" Version="14.7.1">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="OpenAI" Version="2.10.0" />
    <PackageReference Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" Version="18.0.0" />
    <PackageReference Include="YamlDotNet" Version="18.0.0" />
  </ItemGroup>

Thus, Nuget supports a lock file like most other major package managers. To do so, add the RestorePackagesWithLockFile to your .csproj file and when you restore, you’ll get a packages.lock.json file

1
2
3
4
5
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>

Attempt 2 - Using CycloneDX

I then can use another SBOM generation tool, CycloneDX-dotnet to generate the SBOM file. It actually uses the obj/project.assets.json file generated during build, but a lock file is still smart.

1
2
3
4
5
dotnet tool install cyclonedx

dotnet tool run dotnet-CycloneDX -- -F Json LLMProxy.csproj --disable-package-restore --set-version "1.0.0" --recursive

jq '.components[] | ."bom-ref"' bom.json -r

I get the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
pkg:nuget/Microsoft.AspNetCore.OpenApi@10.0.8  
pkg:nuget/Microsoft.DotNet.ILCompiler@10.0.7  
pkg:nuget/Microsoft.Extensions.ApiDescription.Client@8.0.14  
pkg:nuget/Microsoft.NET.ILLink.Tasks@10.0.7  
pkg:nuget/Microsoft.OpenApi@2.0.0  
pkg:nuget/NSwag.ApiDescription.Client@14.7.1  
pkg:nuget/NSwag.MSBuild@14.7.1  
pkg:nuget/OpenAI@2.10.0  
pkg:nuget/System.ClientModel@1.10.0  
pkg:nuget/System.Memory.Data@10.0.3  
pkg:nuget/Vecc.YamlDotNet.Analyzers.StaticGenerator@18.0.0  
pkg:nuget/YamlDotNet@18.0.0

It correctly, contains all of the NuGet dependencies, but is unfortunately missing references for the .net framework itself. I haven’t fixed this problem yet.

Create a Dockerfile

I added a new Docker stage tools that installs CycloneDX and makes it available in the container, but doesn’t run it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["LLMProxy/LLMProxy.csproj", "LLMProxy/"]
COPY ["LLMProxy/packages.lock.json", "LLMProxy/"]
RUN dotnet restore "LLMProxy/LLMProxy.csproj"
COPY . .
WORKDIR "/src/LLMProxy"
RUN dotnet build "./LLMProxy.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM base AS tools  
WORKDIR /src/LLMProxy  
COPY LLMProxy/dotnet-tools.json .  
RUN dotnet tool restore  
COPY --link --from=build /src/LLMProxy /src/LLMProxy

FROM build AS publish
ARG BUILD_CONFIGURATION=Release  
RUN apt-get update \  
    && apt-get install -y --no-install-recommends \  
    clang zlib1g-dev
RUN dotnet publish "./PlaidSync.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
  
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "LLMProxy.dll"]

Running in a workflow

Then, in the workflow, I build the Docker image to the tools target stage mentioned above, and run it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
jobs:
  build:
    - name: Build tools docker image
      id: build_sbom
      uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
      with:
        build-args: BUILD_CONFIGURATION=Debug
        cache-from: |
           type=gha,scope=tools
        cache-to: type=gha,scope=tools
        context: .
        file: ./Dockerfile
        push: false
        platforms: linux/amd64
        target: tools

    - name: Generate dotnet SBOM
      run: |
        set -exo pipefail
        CONTAINER_ID=$(docker create ${STEPS_BUILD_SBOM_OUTPUTS_IMAGEID} dotnet tool run dotnet-CycloneDX -o /sbom -F Json LLMProxy.csproj --disable-package-restore --set-version "$APP_VERSION" --recursive)
        docker start -a $CONTAINER_ID
        docker cp $CONTAINER_ID:/sbom/bom.json ./aot-bom.json
        docker rm $CONTAINER_ID
      env:
        STEPS_BUILD_SBOM_OUTPUTS_IMAGEID: ${{ steps.build_sbom.outputs.imageid }}
        APP_VERSION: ${{ steps.version.outputs.version }}

Generate the container SBOM

The previous step generated the SBOM for the dotnet binary, now we need to build the final image and generate an SBOM with all the libraries in the

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    - name: Build final docker image
      id: build_final
      uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
      with:
        cache-from: |
           type=gha,scope=tools
           type=gha,scope=final
        cache-to: type=gha,scope=final
        context: .
        file: ./Dockerfile
        platforms: linux/amd64
        target: final
        outputs: |
          type=image,push=${{ env.SHOULD_PUSH }}
        tags: ${{ steps.metadata.outputs.tags }}

    - name: Generate Container SBOM
      uses: https://github.com/anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
      with:
        image: ${{ steps.build_final.outputs.imageid }}
        output-file: container-sbom.json
        format: cyclonedx-json
        config: ./.github/syft-config.yaml
        upload-artifact: false

Merging and Converting

Now there’s a dotnet SBOM located at ${{ forge.workspace }}/aot-bom.json. There’s two major formats for SBOMs: CycloneDX and SPDX. I’m sure there’s some advantages of each, but I converted everything to SPDX

1
2
3
4
5
6
7
    - name: Prepare SBOM
      run: |
        set -ex
        wget -nv -O cyclonedx https://github.com/CycloneDX/cyclonedx-cli/releases/download/v0.32.0/cyclonedx-linux-x64
        chmod +x cyclonedx
        ./cyclonedx merge --input-files container-sbom.json ./aot-bom.json --output-file merged-sbom.json
        ./cyclonedx convert --input-file merged-sbom.json --output-format spdxjson --output-file spdx.json

Uploading

Our SPDX format SBOM is located in ${{ forge.workspace }}/spdx.json. I upload it as an artifact:

1
2
3
4
5
6
7
    - name: Upload output
      uses: https://data.forgejo.org/forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4
      with:
        path: |
          merged-sbom.json
          spdx.json
        name: sbom

Then, attach it to the Docker image. This uses the ORAS CLI which is a tool used to upload arbitrary files to a Docker/OCI compatible registry. It creates a tag named sha256-{a hash} that contains the SBOM.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    - uses: https://github.com/oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2
      if: ${{ env.SHOULD_PUSH == 'true' }}
    - name: Upload SBOM
      if: ${{ env.SHOULD_PUSH == 'true' }}
      run: |
        set -exo pipefail
        echo ${STEPS_METADATA_OUTPUTS_TAGS}
        # Iterate through the multi-line tags output from the metadata step
        for TAG in ${STEPS_METADATA_OUTPUTS_TAGS}; do
          echo "Attaching SBOM to $TAG..."
          oras attach "${{ env.DOCKER_REGISTRY }}/${{ env.TARGET_REPO }}@$TAG" \
            --artifact-type application/vnd.spdx+json \
            --plain-http \
            spdx.json:application/json
        done
      env:
        STEPS_METADATA_OUTPUTS_TAGS: ${{ steps.build_final.outputs.imageid }}

Now other consumers of your Docker image can download the SBOM and see the contents. Docker’s built-in SBOM attestation feature differs from this in that it attaches the SBOM directly to the tag you use. I’d love to do this instead, but unfortunately, the built-in SBOM generator isn’t able to identify all the resources I can with this approach.

Scanning for vulnerabilities

With a generated SBOM, we can scan for vulnerabilities using Anchore grype. The following step will look for known vulnerabilities and will fail the build if a high vuln is found.

1
2
3
4
5
6
7
8
    - name: Scan project for vulnerabilities
      if: ${{ env.SHOULD_PUSH == 'true' }}
      uses: https://github.com/anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7
      with:
        cache-db: true
        output-format: table
        sbom: spdx.json
        severity-cutoff: high

Next Steps

Next, I’m going to explore integrating the GitHub Actions workflow steps into the SBOM. Workflow steps seem like they are a weak point in supply chain security with the Trivy vulnerability. Step versions can be identified and attached to the SBOM just like any other package.

I’m also going to explore how Docker performs attestations and signing to see if I can keep the manifests together.

Conclusion

While this post talks primarily about the challenges of generating SBOMs for AOT (ahead-of-time) compiled C#/.net applications due to the resulting OCI image not containing dependency details, this can also apply to other compiled and minified images, like Go or Rust.

I ended up having to create my own SBOMs for two main reasons: C#/.net with publishing did not include dependency versions and Docker SBOM generation used Syft which got confused about .net dependency versions.

Copyright - All Rights Reserved

Comments

To give feedback, send an email to adam [at] this website url.

Donate

If you've found these posts helpful and would like to support this work directly, your contribution would be appreciated and enable me to dedicate more time to creating future posts. Thank you for joining me!

Donate to my blog